Today Access is built by the root action calling a static-unroll local composite (msaccess-vcs-build-all) that invokes DecimalTurn/msaccess-vcs-build up to 5 times (Call action 0..4) via uses:. This exists only because composite actions can't loop over uses:.
Target: Main.ps1 detects Access folders and builds each one directly by invoking vendored copies of the msaccess-vcs-build PowerShell scripts. Result:
- No subaction, no
Call action 0..4cap, no nested composite. - Detection → digest-verify → install → per-folder build all happens in one PowerShell flow.
- The dependency is pinned by the submodule commit (dev) + committed vendored files (runtime).
Scope note:
.accdecompile andapp-config(Prepare-Application.ps1) support — including theaccess-vcs-compile/access-vcs-configinputs and the name-based compile rule — were added to this repo after the first draft of this plan. They are in scope: the folded-in flow must reproduce them (see §5), and the CI fixturetests/AccessExecuteOnlyDatabase.accdeexercises the compile path (see §10).
scripts/
Bundle-Vendors.ps1 # (new) refresh vendors/ from the submodule
Build-VBA.ps1 ... # existing, untouched
vendors/
msaccess-vcs-build/
LICENSE # copied from submodule (see §7)
SOURCE.txt # generated: repo URL + commit SHA (NO timestamp, see §3)
Build.ps1 # copied; EXECUTED per Access folder (approach (b), §5)
action.yml # reference only; NOT executed (documented in SOURCE.txt)
scripts/
Build-Accdb.ps1
Compile-Accdb.ps1 # used for .accde builds
Export-Accdb.ps1
Install-AccUnit.ps1
Install-msaccess-vcs.ps1 # used for the one-time install (§5)
Prepare-Application.ps1 # used when access-vcs-config is set
Remove-TrustedLocation.ps1
Run-AccUnit-Tests.ps1
Set-TrustedLocation.ps1
Get-GitHubHeaders.ps1 # from the rate-limiting fork; auth for digest + install
submodules/
msaccess-vcs-build/ # (dev-only source, kept; NOT needed at runtime)
Design decision: vendors/ is committed. At runtime Main.ps1 only touches
committed files under vendors/ — the submodule is never needed. The submodule exists
purely as the source that Bundle-Vendors.ps1 re-syncs from (only the bundler and the
CI sync-guard job in §9 need it initialized).
Run by maintainers/devs (and by the error message when files are missing). Pinned source: the submodule at msaccess-vcs-build.
[CmdletBinding()]
param(
[string]$SubmodulePath = "submodules/msaccess-vcs-build",
[string]$VendorPath = "vendors/msaccess-vcs-build"
)
$ErrorActionPreference = 'Stop'
$RepoRoot = Split-Path -Parent $PSScriptRoot # scripts/ -> repo root
# --- 1) Ensure the submodule is loaded --------------------------------
Push-Location $RepoRoot
if (-not (Test-Path "$SubmodulePath/.git") -or
-not (Test-Path "$SubmodulePath/Build.ps1")) {
Write-Host "Initializing submodule $SubmodulePath ..."
git submodule update --init --recursive
if ($LASTEXITCODE -ne 0) { throw "git submodule update failed." }
}
if (-not (Test-Path "$SubmodulePath/Build.ps1")) {
throw "Submodule not populated at $SubmodulePath. Run: git submodule update --init --recursive"
}
# --- 2) Record source commit for provenance ---------------------------
$srcCommit = git -C $SubmodulePath rev-parse HEAD
$srcUrl = git -C $SubmodulePath remote get-url origin
# --- 3) Copy the whole upstream tree (decision: vendor everything) -------
# Copy every file/folder in the submodule (Build.ps1, action.yml, README.md,
# LICENSE, scripts/, examples/, ...) except .git, so the vendored scripts
# always match the pinned commit exactly and optional features (compile,
# app-config, AccUnit) never hit a missing-file error later.
New-Item -ItemType Directory -Force -Path $VendorPath | Out-Null
Get-ChildItem -Path $SubmodulePath -Force | Where-Object { $_.Name -ne '.git' } |
Copy-Item -Destination $VendorPath -Recurse -Force
# --- 4) Apply the re-appliable edits from §6 (currently none required) ---
# Reserved for deterministic string replaces. The bundler always re-copies from
# the pristine submodule, so any future edits re-apply cleanly and stay idempotent.
# --- 5) Write provenance file (NO "Bundled:" timestamp) ------------------
# A timestamp would make every regeneration produce a different SOURCE.txt,
# breaking the idempotency test (§10 #2) and the CI sync-guard (`git diff --exit-code`).
@"
Source: $srcUrl
Commit: $srcCommit
Note: vendors/msaccess-vcs-build/action.yml is reference-only and is NOT executed.
Run 'pwsh -File scripts/Bundle-Vendors.ps1' to regenerate.
"@ | Set-Content "$VendorPath/SOURCE.txt"
Write-Host "Vendored msaccess-vcs-build into $VendorPath (commit $srcCommit)"Two working roots, kept distinct (see review #5): the action checkout where the
vendored scripts live is $PSScriptRoot (never Get-Location); the consumer
workspace holding SourceDir is the current directory. There is no shared $RepoRoot.
Extend the param block and plumb the values from action.yml (§8):
param(
[string]$SourceDir = "src",
[string]$TestFramework = "none",
[string]$OfficeAppDetection = "automatic",
# --- Access (new) ---
[string]$AccessVcsUrl = "https://api.github.com/repos/joyfullservice/msaccess-vcs-addin/releases/tags/v5.0.1",
[string]$AccessVcsSha = "", # expected sha256 of the add-in asset; empty = skip verify
[string]$AccessVcsConfig = "", # app-config file for Prepare-Application; empty = skip
[string]$AccessVcsCompile = "" # force compile for ALL Access folders; empty = name-based
)Add (near the other state, or just before use):
$VendorBuildDir = Join-Path $PSScriptRoot 'vendors/msaccess-vcs-build'Missing-file guard (unchanged intent):
function Assert-VendoredVcsBuild {
$required = @('Build.ps1','scripts/Build-Accdb.ps1','scripts/Install-msaccess-vcs.ps1','scripts/Get-GitHubHeaders.ps1')
$missing = $required | Where-Object { -not (Test-Path (Join-Path $VendorBuildDir $_)) }
if ($missing) {
Write-Error @"
Vendored msaccess-vcs-build files are missing from '$VendorBuildDir':
$($missing -join "`n ")
Generate them by running (from the repo root, with the submodule initialized):
git submodule update --init --recursive
pwsh -File scripts/Bundle-Vendors.ps1
"@
exit 1
}
}Digest verification — PowerShell port of the old bash verify-vcs-digest, reimplemented
because Install-msaccess-vcs.ps1 never verifies the zip it downloads. Asset selection
mirrors the installer (Version*.zip first asset), so we verify exactly the file that
will be installed, not .assets[0] (review #7.3):
function Assert-VcsAddinDigest {
param([string]$ReleaseUrl, [string]$ExpectedSha)
if ([string]::IsNullOrEmpty($ExpectedSha)) {
Write-Host "No access-vcs-sha provided - skipping add-in digest verification."
return
}
. "$VendorBuildDir/scripts/Get-GitHubHeaders.ps1"
Write-Host "Verifying add-in digest against: $ReleaseUrl"
$release = Invoke-RestMethod -Uri $ReleaseUrl -Headers (Get-GitHubHeaders)
$asset = $release.assets | Where-Object { $_.name -like 'Version*.zip' } |
Select-Object -First 1
if (-not $asset -or -not $asset.digest) {
throw "No sha256 digest found for the Version*.zip asset. Update the pin."
}
$actual = ($asset.digest -replace '^sha256:','').ToLower()
$expected = $ExpectedSha.Trim().ToLower()
Write-Host "Expected SHA256: $expected`nActual SHA256: $actual"
if ($actual -ne $expected) {
throw "SHA256 digest mismatch for '$($asset.name)'. Expected $expected, got $actual."
}
Write-Host "SHA256 digest verification passed."
}The expected value must be a 64-hex SHA-256 (not a git SHA-1, which is 40 hex) — a known CI pitfall recorded in the repo notes.
Main orchestration — approach (b) (see §5):
function Invoke-AccessBuilds {
param([string[]]$AccessFolders)
Assert-VendoredVcsBuild
# --- digest-verify the add-in release ONCE (auth via GH_TOKEN) --------
Assert-VcsAddinDigest -ReleaseUrl $AccessVcsUrl -ExpectedSha $AccessVcsSha
# --- install the add-in ONCE into the APPDATA default location ---------
# Install-msaccess-vcs.ps1 expands to $TargetDir\MSAccessVCS. TargetDir =
# $env:APPDATA puts it exactly where Build-Accdb.ps1 looks when vcsUrl is
# empty, so per-folder Build.ps1 calls with -vcsUrl "" reuse it without
# re-downloading. Run from a temp dir so msaccess-vcs.zip doesn't pollute
# the workspace.
New-Item -ItemType Directory -Force -Path (Join-Path $env:TEMP "vba-build-$PID") | Out-Null
Push-Location (Join-Path $env:TEMP "vba-build-$PID")
try {
$install = & "$VendorBuildDir/scripts/Install-msaccess-vcs.ps1" `
-vcsUrl $AccessVcsUrl -TargetDir $env:APPDATA -SetTrustedLocation $true
} finally { Pop-Location }
if (-not $install.AddInPath) {
throw "Failed to install the msaccess-vcs add-in (see output above)."
}
Write-Host "msaccess-vcs add-in installed: $($install.AddInPath)"
# --- per folder: fresh child pwsh running the vendored Build.ps1 -------
# Phase 1 uses a separate pwsh per folder for isolation while we validate
# the new flow. Phase 2 (follow-up) drops the child pwsh and calls Build.ps1
# in-process for speed.
foreach ($folder in $AccessFolders) {
# Replicate the old bash parse-step: per-folder compile flag.
$compile = if (-not [string]::IsNullOrEmpty($AccessVcsCompile)) { $AccessVcsCompile }
elseif ($folder -match '\.accde(\.src)?$') { 'true' }
else { 'false' }
Write-Host "Building Access database: $folder (compile=$compile)"
& pwsh -NoProfile -File "$VendorBuildDir/Build.ps1" `
-SourceDir $folder `
-TargetDir "$SourceDir/out" `
-Compile $compile `
-AppConfigFile $AccessVcsConfig `
-vcsUrl ""
if ($LASTEXITCODE -ne 0) {
throw "Access build failed for $folder (exit code $LASTEXITCODE)."
}
}
}After the existing per-folder loop (Access folders are currently collected there and
continued) and before the GITHUB_OUTPUT writes:
if ($hasAccessDatabase) {
Invoke-AccessBuilds -AccessFolders $accessFolders
}Why not call Build-Accdb.ps1 directly (the earlier plan draft): it bypasses Build.ps1
and therefore silently drops compile (.accde) and app-config
(Prepare-Application.ps1), which this repo now needs.
Mechanics of (b):
Build.ps1has no-VcsAddInPathparameter — it installs the add-in whenvcsUrlis set, and with an emptyvcsUrlit passes an empty add-in path toBuild-Accdb.ps1, which then falls back to the APPDATA default:%APPDATA%\MSAccessVCS\Version Control.accd[ae].- Once per run:
Install-msaccess-vcs.ps1 -TargetDir $env:APPDATAexpands the add-in to exactly that default location (and sets its trusted location). One download, one install. - Per folder (Phase 1): spawn a fresh child
pwshthat runs the vendoredBuild.ps1with-vcsUrl ""(plus-Compile <flag>and-AppConfigFile <config>when applicable). BecausevcsUrlis empty,Build.ps1skips the install, finds the pre-installed add-in at the APPDATA default, and otherwise keeps its full upstream behavior: per-folder timestamped trusted location (set + removed), build, optional compile →.accde, optionalPrepare-Application(Pre/PostCompile staging around the compile). Each call still gets a fresh Access instance (Build-Accdb.ps1starts, quits, and force-kills its ownMSACCESS). - Per-folder compile flag: name-based (
*.accde[.src]) unlessaccess-vcs-compileis set, exactly reproducing the old bashparsestep. - Net effect: 1 add-in download/install; N fresh Access instances; full feature parity
(incl.
.accde+app-config); noCall action 0..4cap.
Phase 2 (follow-up, NOT part of this change): once the fold-in is validated, drop the
child pwsh and invoke the vendored Build.ps1 in-process (&, checking
$LASTEXITCODE) — Build-Accdb.ps1 already isolates Access per call, so the child process
adds no Access isolation, only process fault isolation.
Under (b) Build.ps1 is always invoked with an explicit -vcsUrl "", and
Install-msaccess-vcs.ps1 with explicit -vcsUrl / -TargetDir, so their defaults are
never hit. Auth already ships in the fork. Therefore no string edits are required
today; bundler step 4 is reserved for future deterministic replaces. Items to verify (no
edit):
- Auth (
Get-GitHubHeaders.ps1): present in the fork and used byInstall-msaccess-vcs.ps1(and now by the digest check). Verify it is the file bundled. action.yml: vendored for reference only; not executed (documented inSOURCE.txt).- Trusted-location uniqueness:
Build.ps1uses timestamped location names per call; repeated per-folder invocations don't collide. Leftover registry entries after a run are acceptable (CI is ephemeral; dev machines accumulate a harmless entry).
- Copy the submodule's
LICENSEintovendors/msaccess-vcs-build/LICENSEunchanged. - Generate
vendors/msaccess-vcs-build/SOURCE.txtwith: source URL, bundled commit SHA, regeneration command. No bundle date (idempotency, §3/§10). - If any script files carry their own license headers, keep them intact (no stripping).
- Optional: add a top-level note in
README.mdthatvendors/msaccess-vcs-buildis vendored fromDecimalTurn/msaccess-vcs-buildat a pinned commit.
After Main.ps1 handles Access:
- "Run VBA Build" step now also passes the Access inputs:
- name: "Run VBA Build"
id: "run_vba_build"
shell: pwsh
run: |
${{ github.action_path }}\Main.ps1 `
-SourceDir "${{ inputs['source-dir'] }}" `
-TestFramework "${{ inputs['test-framework'] }}" `
-OfficeAppDetection "${{ inputs['office-app'] }}" `
-AccessVcsUrl "${{ inputs['access-vcs-url'] }}" `
-AccessVcsSha "${{ inputs['access-vcs-sha'] }}" `
-AccessVcsConfig "${{ inputs['access-vcs-config'] }}" `
-AccessVcsCompile "${{ inputs['access-vcs-compile'] }}"- Delete the
Build Access Database (if detected)step thatuses: ./subactions/msaccess-vcs-build-all. - Delete
subactions/msaccess-vcs-build-all/(itsparse+verify-vcs-digestlogic now lives inMain.ps1). - Keep
access-folders/has-access-databaseoutputs (Main.ps1 already writes them). - Keep the "Expose GitHub token to environment" step —
Main.ps1's digest check and the install readGH_TOKEN. - Keep the submodule (it feeds
Bundle-Vendors.ps1). - Adjacent cleanup while here: the
first-access-folderoutput references a non-existentsteps.extract_access_folderstep (pre-existing dead output) — remove or fix it.
When vendors/ isn't present (fresh clone where the maintainer forgot to run the bundler):
Error: Vendored msaccess-vcs-build files are missing from 'vendors/msaccess-vcs-build':
Build.ps1
scripts/Build-Accdb.ps1
scripts/Install-msaccess-vcs.ps1
Generate them by running (from the repo root, with the submodule initialized):
git submodule update --init --recursive
pwsh -File scripts/Bundle-Vendors.ps1
CI only hits this if the commit didn't include vendors/. Add a CI guard job that re-runs
the bundler and fails if it produces changes (keeps vendors/ in sync). Note
actions/checkout does not fetch submodules by default, so the guard must request them:
guard-vendors:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- name: "Check vendors/ is in sync with the submodule"
shell: pwsh
run: |
pwsh -File scripts/Bundle-Vendors.ps1
git diff --exit-code -- vendors/
if ($LASTEXITCODE -ne 0) {
Write-Error "vendors/ is out of sync. Run scripts/Bundle-Vendors.ps1 and commit the result."
exit 1
}
Write-Host "vendors/ is in sync."pwsh -File scripts/Bundle-Vendors.ps1→git statusshows only the expected vendor tree +SOURCE.txt.- Idempotency: re-run the bundler → no further diff (holds because
SOURCE.txthas no timestamp). - Local parse check of every vendored
.ps1:[System.Management.Automation.Language.Parser]::ParseFile(...). - CI
Test Build VBA(source-dir./tests) must still detect both Access exports, verify the digest against the default pin (9504af3d…), and produce:tests/AccessDatabase.accdb(non-compile, name-based) →tests/out/Testing.accdb;tests/AccessExecuteOnlyDatabase.accde(compile, name-based) → compiled.accdeintests/out/. (This covers both the plain build and the new-in-scope compile path.)
- Compile / app-config smoke: run with
access-vcs-compile: trueandaccess-vcs-config: tests/Application-Config.jsonto exercise thePrepare-ApplicationPre/PostCompile paths. - Negative test: temporarily rename
vendors/msaccess-vcs-build→ expect the clear error message above. -
5 Access folders regression: temporarily copy the real fixtures (they must stay UTF-8-with-BOM + CRLF, per the repo notes — hand-made fixtures fail the add-in build) into >5 folders and confirm all build (cap gone).
The subaction and submodules stay in git history; reverting the fold-in is a git revert.
vendors/ commit is additive and doesn't affect non-Access builds.
Decisions:
- Build execution (b): install the add-in once into
%APPDATA%(the default locationBuild-Accdb.ps1falls back to), then run the vendoredBuild.ps1per folder with-vcsUrl ""in a fresh child pwsh (Phase 1). Phase 2 = in-process optimization (follow-up) (see §5). - Feature parity is in scope: compile (
.accde) andapp-config(Prepare-Application) are preserved by calling fullBuild.ps1; the per-folder compile-flag logic moves from bash intoMain.ps1. - Vendoring scope: copy the whole upstream tree into
vendors/msaccess-vcs-build(see §3). - Digest verification is reimplemented in PowerShell, selects the same
Version*.zipasset the installer downloads, and runs before the install; skipped whenaccess-vcs-shais empty (see §4, review #7.3). $VendorBuildDir = $PSScriptRoot/vendors/msaccess-vcs-build(action checkout); install target =$env:APPDATA. No shared$RepoRoot(review #5).SOURCE.txthas no timestamp so the bundler is byte-idempotent and the CI sync-guard works (see §3, §9).- Keep the submodule in this repo — it is the source
Bundle-Vendors.ps1copies from (runtime CI only uses committedvendors/).