From 0cf86a9fb86dfae3ea8b26ebe1826820cdd0d83b Mon Sep 17 00:00:00 2001 From: Janghoon Lee <44862514+savagemanage@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:33:59 +0000 Subject: [PATCH] Use the shared gitflow, Windows signing and release publish gitflow.yml is now a verbatim copy of the redrob-labs/.github caller template; the branch-name and back-merge rules live in the organization workflow, which also opens the back-merge pull request, so branch-sync.yml is deleted. The repository's own tag rule moves to tag-hygiene.yml unchanged. Branch types follow the organization list (feat/ instead of feature/). release.yml keeps version resolution, the Bun build, macOS notarization, the Linux archives, the launcher asset copies, the VERSION marker and build provenance. The inline Windows signing recipe (script/sign-windows.ps1 and its read-back steps) is replaced by redrob-labs/.github/actions/sign-windows, called after each azure/login in the same build -> login -> sign order; a failed signature now fails the job instead of degrading to an unsigned release. The publish job is release-publish.yml with publish: true and launcher_manifest: true; because this release is dispatch-driven, a tag job pushes the annotated tag first and the tag and commit are passed to the shared job explicitly. --- .github/workflows/branch-sync.yml | 61 ----- .github/workflows/gitflow.yml | 128 +-------- .github/workflows/release.yml | 433 +++++++----------------------- .github/workflows/tag-hygiene.yml | 68 +++++ AGENTS.md | 37 ++- docs/BRANCHING.md | 64 +++-- docs/LOCAL-ENGINE-API.md | 2 +- script/sign-windows.ps1 | 104 ------- 8 files changed, 231 insertions(+), 666 deletions(-) delete mode 100644 .github/workflows/branch-sync.yml create mode 100644 .github/workflows/tag-hygiene.yml delete mode 100644 script/sign-windows.ps1 diff --git a/.github/workflows/branch-sync.yml b/.github/workflows/branch-sync.yml deleted file mode 100644 index 0b0e486c91..0000000000 --- a/.github/workflows/branch-sync.yml +++ /dev/null @@ -1,61 +0,0 @@ -name: branch-sync - -# Fails when `main` carries a commit `develop` does not, which is the back-merge step being skipped. -# -# `docs/BRANCHING.md` has told hotfixes to merge `main` back into `develop` since it was written. -# The rule was not the problem: #13 fixed `bun install --frozen-lockfile` on `main` and the back-merge -# never happened, so the DEFAULT branch could not install with the bun version `packageManager` pins -# for a month. Nothing anywhere said so. Documentation that nothing checks is a preference. -# -# On `push` to `main` rather than on a schedule, because that is the moment the divergence is -# created and the person who created it is still looking. A promotion pull request from `develop` -# leaves the two identical and says nothing; a hotfix merged straight into `main` reports until it -# is brought back. -# -# It FAILS rather than posting a notice. A notice on a branch nobody watches is how this went -# unnoticed the first time, and a red mark on `main` is the point: the repository is in a state -# where a branch cut from the default branch inherits a known-broken tree. - -on: - push: - branches: [main] - # Lets the check be re-run once the back-merge has landed, without pushing a commit. - workflow_dispatch: - -permissions: - contents: read - -jobs: - back-merged: - name: main is back-merged into develop - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - # Full history: `rev-list` between two branches needs the commits, and a shallow clone - # answers with whatever it happens to have, which is worse than no answer. - fetch-depth: 0 - - - name: Compare main against develop - run: | - set -euo pipefail - if ! git rev-parse -q --verify origin/develop >/dev/null; then - echo "::notice::no develop branch; nothing to back-merge into" - exit 0 - fi - missing="$(git rev-list --count origin/develop..origin/main)" - if [ "$missing" -eq 0 ]; then - echo "main is contained in develop" - exit 0 - fi - { - echo "### \`main\` has $missing commit(s) \`develop\` does not" - echo - echo "Merge \`main\` back into \`develop\`, per docs/BRANCHING.md. Until that lands, a" - echo "branch cut from the default branch is missing whatever these changed." - echo - git log --format='- `%h` %s' origin/develop..origin/main - } >> "$GITHUB_STEP_SUMMARY" - git log --oneline origin/develop..origin/main - echo "::error::main is $missing commit(s) ahead of develop; back-merge it" - exit 1 diff --git a/.github/workflows/gitflow.yml b/.github/workflows/gitflow.yml index 337c1c76c3..8c75c9c488 100644 --- a/.github/workflows/gitflow.yml +++ b/.github/workflows/gitflow.yml @@ -1,127 +1,21 @@ -name: gitflow +# Gitflow rules shared by every redrob-labs repository: branch names are / from the +# org-wide type list, and main is contained in develop (a back-merge pull request is opened when +# it is not). The rules live in redrob-labs/.github/.github/workflows/gitflow.yml; this file only +# calls them. Do not add a type list here -- there is one, and it is there. -# The back-merge half of the shared gitflow workflow is NOT here: `branch-sync.yml` owns that rule. -# -# One rule this repository already had in prose, now checked. -# -# A rule nothing checks is a preference. It was written down and broken anyway: branches appeared -# under a `kiro/` prefix that no document defines. -# -# A branch name can only be judged when a pull request exists, which is why this runs on -# `pull_request` and not on push. +name: gitflow on: pull_request: - # No branch filter. A filter that omits the default branch silently stops running, which is exactly - # what happened in a sibling repository when the default moved to `develop` and three workflows - # kept filtering on `[main, master]`. types: [opened, reopened, edited, synchronize] + push: + branches: [main] workflow_dispatch: permissions: - contents: read + contents: write + pull-requests: write jobs: - tag-hygiene: - name: no workflow pushes tags in bulk - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 - with: - fetch-depth: 1 - - - name: Refuse bulk tag pushes and unnamespaced upstream fetches - run: | - set -euo pipefail - - # This fork inherited over a thousand upstream tags, which buried its own releases and - # made a `v*` glob deletion unusable. They were deleted. Two command shapes would undo - # that in one run, so neither may appear in a workflow: - # - # a tag push with no tag named republishes whatever is in refs/tags - # a tag fetch from `upstream` refills refs/tags from upstream - # - # The release workflow pushes exactly one tag, named in full as `refs/tags/$TAG`, and - # that is the only allowed shape. Fetching OUR OWN tags from `origin` is fine and is not - # matched: the release-tagging actions depend on it. See docs/VERSIONING.md. - # - # Comment lines are stripped before matching. Without that this gate fails on the very - # comment above, which names both banned shapes in order to explain them. - bad=0 - while IFS= read -r file; do - body="$(sed 's/[[:space:]]*#.*$//' "$file")" - - if printf '%s\n' "$body" | grep -nE 'git[[:space:]]+push[^|&;]*--(tags|follow-tags)'; then - echo "::error file=$file::pushes tags in bulk; push one tag by its full refs/tags/" - bad=1 - fi - - # Only upstream's tags are the problem, and only when no refspec redirects them out - # of refs/tags. - if printf '%s\n' "$body" \ - | grep -E 'git[[:space:]]+fetch[^|&;]*--tags[^|&;]*upstream|git[[:space:]]+fetch[^|&;]*upstream[^|&;]*--tags' \ - | grep -qv 'refs/upstream-tags/'; then - echo "::error file=$file::fetches upstream tags into refs/tags; use '+refs/tags/*:refs/upstream-tags/*'" - bad=1 - fi - done < <(find .github/workflows -type f \( -name '*.yml' -o -name '*.yaml' \)) - - if [ "$bad" -ne 0 ]; then - exit 1 - fi - echo "no bulk tag pushes and no unnamespaced upstream tag fetches" - - branch-name: - name: branch name follows the convention - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - steps: - - name: Check the head branch name - env: - HEAD_REF: ${{ github.head_ref }} - BASE_REF: ${{ github.base_ref }} - run: | - set -euo pipefail - - # ALLOWED_TYPES is this repository's own list. Keep it in step with the branch table in the - # contributing or branching document; the document is the explanation, this is the gate. - ALLOWED_TYPES='feature sync hotfix release' - - # `develop` and `main` reach here through a promotion or a back-merge pull request, which are - # branches in their own right and are not named after a type. - case "$HEAD_REF" in - develop|main) echo "$HEAD_REF is a long-lived branch, not a working branch"; exit 0 ;; - esac - - type="${HEAD_REF%%/*}" - slug="${HEAD_REF#*/}" - - if [ "$type" = "$HEAD_REF" ]; then - echo "::error::branch '$HEAD_REF' has no type prefix. Use /, type one of: $ALLOWED_TYPES" - exit 1 - fi - - ok=false - for t in $ALLOWED_TYPES; do - [ "$type" = "$t" ] && ok=true && break - done - - if [ "$ok" != true ]; then - { - echo "### Branch name is not one of this repository's types" - echo - echo "\`$HEAD_REF\` uses the prefix \`$type\`. Allowed: $ALLOWED_TYPES" - echo - echo "A branch name says what the change is, not who or what made it. An agent or tool name" - echo "is not a type: rename the branch and reopen the pull request." - } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" - echo "::error::branch prefix '$type' is not allowed. Use one of: $ALLOWED_TYPES" - exit 1 - fi - - if [ -z "$slug" ] || [ "$slug" = "$type" ]; then - echo "::error::branch '$HEAD_REF' has an empty slug. Use /." - exit 1 - fi - - echo "$HEAD_REF is a valid $type branch targeting $BASE_REF" + gitflow: + uses: redrob-labs/.github/.github/workflows/gitflow.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1d988b4db4..1af8c8b817 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -240,7 +240,7 @@ jobs: - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: cli-macos-signed + name: release-macos path: dist/redrob-darwin-*.zip # `if: always()` so a failed notarization still takes the private key off the runner. @@ -261,44 +261,20 @@ jobs: contents: read # The OIDC token `azure/login` exchanges for the Artifact Signing credential. id-token: write - outputs: - # Carried to `publish` so the release notes state what actually happened. Notes that - # claim a signature the run did not produce are worse than no claim: a reader has no - # way to tell, and the whole point of saying "signed" is that it can be relied on. - signed: ${{ steps.result.outputs.signed }} - # Separate from `signed`: the binaries can be signed while the installer is not, and the - # notes must not round one up into the other. - installer: ${{ steps.installer-result.outputs.installer }} - # WHICH method signed, not just whether something did (`azure` or `none`). The notes used - # to name a method unconditionally while the run had used another, so every published - # release claimed a method it had not used. `signed` cannot carry that -- it is a boolean - # about success -- and a reader has no way to check. - method: ${{ steps.config.outputs.method }} env: - # All plain IDs, none a credential. The redrob-labs organization secrets; do not recreate - # them as repository secrets (a same-named repository secret would override them). - # AZURE_CLIENT_ID is here so the login step can be skipped when signing is not set up. - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - # sign-windows.ps1 reads these three; the credential comes from the `az login` below. - ARTIFACT_SIGNING_ENDPOINT: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }} - ARTIFACT_SIGNING_ACCOUNT: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT }} - ARTIFACT_SIGNING_CERT_PROFILE: ${{ secrets.ARTIFACT_SIGNING_CERT_PROFILE }} REDROB_WINDOWS_BINARIES: >- dist/redrob-windows-arm64/bin/redrob.exe dist/redrob-windows-x64/bin/redrob.exe dist/redrob-windows-x64-baseline/bin/redrob.exe REDROB_WINDOWS_INSTALLER: dist/redrob-code-x64-setup.exe steps: - # This job used to download the build and nothing else, because signing needs only the - # artifact. The installer definition and the signing script live in the repository, so it - # needs a checkout -- and without one `ISCC` was handed a path to a file that was never on - # the runner. Sparse, because cloning this whole repository to read two directories is a - # minute of clone for nothing. + # Only the installer definition is needed from the repository; the signing recipe is the + # organization's (redrob-labs/.github/actions/sign-windows), not a copy kept here. Sparse, + # because cloning this whole repository to read one directory is a minute of clone for nothing. - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: sparse-checkout: | installer - script sparse-checkout-cone-mode: true - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 @@ -309,113 +285,38 @@ jobs: # Azure Artifact Signing, authenticated with OpenID Connect: the job's GitHub token is # exchanged for an Azure token against the organization's app registration, whose federated # credential accepts `repo:redrob-labs@/*:environment:release`. No certificate file - # and no client secret exist anywhere. The choice to sign is made once, up front, from the - # three ARTIFACT_SIGNING_* names ALONE, so a half-configured account is reported as such - # rather than failing inside signtool with an unrelated message. - - name: Detect the signing configuration - id: config - shell: bash - run: | - set -euo pipefail - present=0 - missing=() - for name in ARTIFACT_SIGNING_ENDPOINT ARTIFACT_SIGNING_ACCOUNT ARTIFACT_SIGNING_CERT_PROFILE; do - if [ -n "${!name:-}" ]; then present=$((present + 1)); else missing+=("$name"); fi - done - # A PARTIAL configuration is a mistake, not a choice, so it is fatal rather than - # silently degraded to an unsigned build. - if [ "$present" -gt 0 ] && [ "$present" -lt 3 ]; then - echo "::error::partial Artifact Signing configuration; missing: ${missing[*]}" - exit 1 - fi - if [ "$present" -eq 3 ]; then - echo "method=azure" >> "$GITHUB_OUTPUT" - echo "::notice::signing Windows binaries with Azure Artifact Signing" - else - echo "method=none" >> "$GITHUB_OUTPUT" - echo "::warning::Artifact Signing is not configured; the binaries will be unsigned" - fi - - # The login comes AFTER the download and right BEFORE signing: GitHub's OIDC token lives - # minutes, and a login done earlier left signtool without a usable credential. + # and no client secret exist anywhere. The login comes AFTER the download and right BEFORE + # signing: GitHub's OIDC token lives minutes, and a login done earlier left signtool without + # a usable credential. - name: Log in to Azure (OIDC) - if: steps.config.outputs.method == 'azure' && env.AZURE_CLIENT_ID != '' uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - # The script signs, then reads every file back (`signtool verify /pa /v` and - # Get-AuthenticodeSignature must say Valid) and prints ` signed by `. - - name: Sign with Azure Artifact Signing - id: sign-azure - continue-on-error: true - if: steps.config.outputs.method == 'azure' - shell: pwsh - run: | - $files = $env:REDROB_WINDOWS_BINARIES -split '\s+' - ./script/sign-windows.ps1 @files - - # Reads the signature back off the artifacts rather than trusting the signing step's - # exit code. This is what makes "signed" in the release notes a checked claim: the - # previous release said Windows was signed while nothing had signed it. A Public Trust - # certificate chains to a root every Windows trusts, so only `Valid` passes, and the - # signer must be the organization's own certificate. - - name: Verify the Windows signatures - id: verify - continue-on-error: true - if: steps.config.outputs.method != 'none' - shell: pwsh - run: | - $ErrorActionPreference = "Stop" - foreach ($file in ($env:REDROB_WINDOWS_BINARIES -split '\s+')) { - $sig = Get-AuthenticodeSignature $file - if (-not $sig.SignerCertificate) { throw "no Authenticode signer certificate on ${file}" } - if ($sig.Status -ne "Valid") { throw "unexpected signature status on ${file}: $($sig.Status) $($sig.StatusMessage)" } - # The parsed CN, not a regex over the Subject string: the Subject renders as - # `CN="McKinley Rice, Inc.", O=...` (quoted, because the name contains a comma). - if ($sig.SignerCertificate.GetNameInfo('SimpleName', $false) -ne 'McKinley Rice, Inc.') { - throw "unexpected signer on ${file}: $($sig.SignerCertificate.Subject)" - } - Write-Host "$file signed by $($sig.SignerCertificate.Subject)" - } - - - name: Record the signing outcome - id: result - if: always() - shell: bash - run: | - set -euo pipefail - method="${{ steps.config.outputs.method }}" - case "$method" in - azure) signed="${{ steps.sign-azure.outcome }}" ;; - *) signed="skipped" ;; - esac - # Both the signing step AND the read-back have to succeed. Either alone can be - # green while the artifacts are unsigned. - if [ "$signed" = "success" ] && [ "${{ steps.verify.outcome }}" = "success" ]; then - echo "signed=true" >> "$GITHUB_OUTPUT" - echo "::notice::Windows binaries signed via $method and verified" - else - echo "signed=false" >> "$GITHUB_OUTPUT" - echo "::warning::the Windows binaries in this release are NOT signed" - fi + # The shared action signs, then reads every file back (`signtool verify /pa`, Status Valid, + # signer CN `McKinley Rice, Inc.`) and prints ` signed by CN=...`. With the three + # ARTIFACT_SIGNING_* secrets all unset it warns and leaves the files unsigned; half-set is an + # error. A failed signature fails the job: a release never ships an unsigned Windows binary + # behind a green check. + - name: Sign the binaries + uses: redrob-labs/.github/actions/sign-windows@main + with: + files: ${{ env.REDROB_WINDOWS_BINARIES }} + description: Redrob Code + url: https://github.com/${{ github.repository }} + endpoint: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }} + account: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT }} + cert-profile: ${{ secrets.ARTIFACT_SIGNING_CERT_PROFILE }} # ── The installer ───────────────────────────────────────────────────────────────────── # - # Built HERE rather than in `build`, and specifically after the signing steps above, because + # Built HERE rather than in `build`, and specifically after the signing step above, because # the installer embeds the binary: building it earlier would wrap an unsigned redrob.exe in a # signed installer, so Windows would accept the download and then ship an unsigned program. - # Signing is therefore two-stage -- payload first, wrapper second -- and both stages are read - # back before the release notes are allowed to claim either. + # Signing is therefore two-stage -- payload first, wrapper second. - name: Build the Windows installer - id: installer - # continue-on-error, like every other step in this job. The installer is an ADDITION to the - # release; a failure here must not stop the binaries being repacked and uploaded. The first - # run proved the point -- the build failed and took Repack and the upload with it, so a - # missing installer would have meant no Windows assets at all. - continue-on-error: true shell: pwsh run: | $ErrorActionPreference = "Stop" @@ -438,59 +339,21 @@ jobs: # the installer build above takes minutes, and the credential must not be assumed to have # outlived it. - name: Log in to Azure again (OIDC) - if: steps.config.outputs.method == 'azure' && steps.installer.outcome == 'success' && env.AZURE_CLIENT_ID != '' uses: azure/login@a641126d1b8aa4d1fa005f4f92df94a3a4c4c906 # v3 with: client-id: ${{ secrets.AZURE_CLIENT_ID }} tenant-id: ${{ secrets.AZURE_TENANT_ID }} subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - name: Sign the installer with Azure Artifact Signing - id: sign-installer-azure - continue-on-error: true - if: steps.config.outputs.method == 'azure' && steps.installer.outcome == 'success' - shell: pwsh - run: ./script/sign-windows.ps1 $env:REDROB_WINDOWS_INSTALLER - - # Read back, on the same terms as the binaries: an unsigned installer is the one file a user - # actually double-clicks, so it is the last thing that should be trusted on an exit code. - - name: Verify the installer signature - id: verify-installer - continue-on-error: true - if: steps.config.outputs.method != 'none' && steps.installer.outcome == 'success' - shell: pwsh - run: | - $ErrorActionPreference = "Stop" - $setup = $env:REDROB_WINDOWS_INSTALLER - $sig = Get-AuthenticodeSignature $setup - if (-not $sig.SignerCertificate) { throw "no Authenticode signer certificate on the installer" } - if ($sig.Status -ne "Valid") { throw "unexpected signature status on the installer: $($sig.Status) $($sig.StatusMessage)" } - if ($sig.SignerCertificate.GetNameInfo('SimpleName', $false) -ne 'McKinley Rice, Inc.') { - throw "unexpected signer on the installer: $($sig.SignerCertificate.Subject)" - } - Write-Host "$setup signed by $($sig.SignerCertificate.Subject)" - - - name: Record the installer signing outcome - id: installer-result - if: always() - shell: bash - run: | - set -euo pipefail - method="${{ steps.config.outputs.method }}" - case "$method" in - azure) signed="${{ steps.sign-installer-azure.outcome }}" ;; - *) signed="skipped" ;; - esac - if [ "${{ steps.installer.outcome }}" != "success" ]; then - echo "installer=false" >> "$GITHUB_OUTPUT" - echo "::warning::the Windows installer was not built" - elif [ "$signed" = "success" ] && [ "${{ steps.verify-installer.outcome }}" = "success" ]; then - echo "installer=true" >> "$GITHUB_OUTPUT" - echo "::notice::Windows installer signed via $method and verified" - else - echo "installer=false" >> "$GITHUB_OUTPUT" - echo "::warning::the Windows installer in this release is NOT signed" - fi + - name: Sign the installer + uses: redrob-labs/.github/actions/sign-windows@main + with: + files: ${{ env.REDROB_WINDOWS_INSTALLER }} + description: Redrob Code + url: https://github.com/${{ github.repository }} + endpoint: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }} + account: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT }} + cert-profile: ${{ secrets.ARTIFACT_SIGNING_CERT_PROFILE }} - name: Repack shell: bash @@ -504,19 +367,23 @@ jobs: - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: - name: cli-windows-signed + name: release-windows path: | dist/redrob-windows-*.zip dist/redrob-code-x64-setup.exe + if-no-files-found: error - publish: + # The assets only this repository knows how to shape: the Linux archives, the Redrob Launcher + # copies, the `VERSION` marker and the build provenance. Checksums, the launcher manifest and the + # release itself are the organization's job (`publish` below). + assets: needs: [version, build, sign-macos, sign-windows] runs-on: ubuntu-latest steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 with: ref: ${{ needs.version.outputs.sha }} - fetch-depth: 0 + fetch-depth: 1 - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: @@ -525,17 +392,14 @@ jobs: - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 with: - name: cli-macos-signed - path: assets - - - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 - with: - name: cli-windows-signed - path: assets + pattern: release-* + path: signed + merge-multiple: true - name: Pack the Linux binaries run: | set -euo pipefail + mkdir -p assets cd unsigned for directory in redrob-linux-*; do [ -d "$directory" ] || continue @@ -557,11 +421,6 @@ jobs: # them verbatim -- and the launcher's are added beside them. See the script's header for the # layout inside; `test/installation/launcher-assets.test.ts` guards both. # - # The same step writes `redrob-launcher.json`, the release manifest the launcher reads to - # learn this release uses its shared account file (`{ "schema": 1, "sharedAccount": true }`), - # and refuses to finish if the release directory lacks it. It is checksummed below like - # every other asset so the launcher's quota-free path, which lists SHA256SUMS, sees it. - # # Bun alone, without the repository's dependencies: the script imports nothing but Bun and # Node built-ins, and `bun install` here would cost minutes for nothing. - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 @@ -574,33 +433,11 @@ jobs: run: | set -euo pipefail bun ./packages/redrob/script/launcher-assets.ts \ - --version "$VERSION" --unsigned unsigned --signed assets --out assets - - - name: Checksums - working-directory: assets - run: | - set -euo pipefail - # The installer is included deliberately: it is the file a Windows user actually - # double-clicks, so it is the one that most needs a published digest. Guarded with a - # glob test rather than assumed, because the installer step is `continue-on-error` - # and a release without it must still produce checksums for everything else. - set -- redrob-code-x64-setup.exe - if [ -e "$1" ]; then - sha256sum redrob-*.zip redrob-*.tar.gz redrob-launcher.json redrob-code-x64-setup.exe > SHA256SUMS - else - echo "::warning::no installer present; SHA256SUMS will not cover one" - sha256sum redrob-*.zip redrob-*.tar.gz redrob-launcher.json > SHA256SUMS - fi - grep -q ' redrob-launcher.json$' SHA256SUMS || { echo "::error::SHA256SUMS does not list redrob-launcher.json"; exit 1; } - cat SHA256SUMS + --version "$VERSION" --unsigned unsigned --signed signed --out assets # The marker install.sh and `redrob upgrade` read to answer "what is the newest - # version". Written AFTER the checksums so it is deliberately not one of the files - # SHA256SUMS covers: it names a version rather than carrying a payload, and the - # archive it points at is checksum-verified once fetched. - # - # `releases/latest/download/VERSION` resolves to whichever release is newest, so no - # client has to hardcode a tag and nothing here consults the GitHub API, which is + # version". `releases/latest/download/VERSION` resolves to whichever release is newest, + # so no client has to hardcode a tag and nothing here consults the GitHub API, which is # rate limited to 60 requests an hour for an anonymous caller. - name: Write the latest-version marker working-directory: assets @@ -615,95 +452,46 @@ jobs: uses: actions/attest-build-provenance@4d101475d8b20a2381f78447822ac1eab6504dd8 # v4.2.2 with: subject-path: | + signed/redrob-*.zip assets/redrob-*.zip assets/redrob-*.tar.gz - - name: Create the release - if: ${{ !inputs.dry_run }} + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-linux-and-launcher + path: assets/* + if-no-files-found: error + + # The release is dispatch-driven, so the workflow creates the tag itself: `publish` below + # releases exactly this tag at exactly the commit `version` resolved. + tag: + needs: [version, build, sign-macos, sign-windows, assets] + if: ${{ !inputs.dry_run }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + ref: ${{ needs.version.outputs.sha }} + fetch-depth: 0 + + - name: Create and push the tag env: - GH_TOKEN: ${{ github.token }} TAG: ${{ needs.version.outputs.tag }} UPSTREAM: ${{ needs.version.outputs.upstream }} - WINDOWS_SIGNED: ${{ needs.sign-windows.outputs.signed }} - WINDOWS_INSTALLER: ${{ needs.sign-windows.outputs.installer }} - WINDOWS_SIGNING_METHOD: ${{ needs.sign-windows.outputs.method }} run: | set -euo pipefail # An annotated tag records a tagger, and a bare runner has no git identity, so # `git tag -a` fails with "empty ident name" AFTER the build, the signing and the # notarization have all succeeded -- the most expensive possible place to # discover it. The tag stays annotated rather than lightweight because it is what - # a release points at and it should say who made it and when. + # a release points at and it should say who made it and when, and its message + # carries the upstream version the release notes no longer state. git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - { - echo "Built on [opencode v$UPSTREAM](https://github.com/anomalyco/opencode/releases/tag/v$UPSTREAM)." - echo - echo "Redrob Code has its own version line. This number is not upstream's and does" - echo "not track it: what this build is based on is recorded in \`UPSTREAM_VERSION\`" - echo "and stated above. See \`docs/VERSIONING.md\`." - echo - echo "## Installing" - echo - echo "macOS and Linux:" - echo - echo '```sh' - echo "curl -fsSL https://console.redrob.ai/code/install.sh | sh" - echo '```' - echo - echo "Windows, in PowerShell:" - echo - echo '```powershell' - echo "irm https://console.redrob.ai/code/install.ps1 | iex" - echo '```' - echo - echo "These releases are the only place builds are published. There is no CDN" - echo "mirror, and an install from before 0.1.0 does not upgrade into this line:" - echo "run the command above again to move onto it." - echo - echo "## Verifying" - echo - echo "macOS binaries are signed with a Developer ID certificate and notarized." - if [ "$WINDOWS_INSTALLER" = "true" ]; then - echo "Windows has a signed installer: download \`redrob-code-x64-setup.exe\` and run it." - echo "It installs to \`%LOCALAPPDATA%\\Redrob\\bin\`, adds that to your user PATH, and" - echo "needs no Administrator rights. Open a NEW terminal afterwards -- an already-open" - echo "one still holds the old PATH." - echo - fi - if [ "$WINDOWS_SIGNED" = "true" ]; then - # Named from what the run actually did. An unrecognised value is described - # without naming a method rather than guessed at: a wrong provenance claim is - # worse than a vaguer true one, because a reader cannot tell it is wrong. - case "${WINDOWS_SIGNING_METHOD:-}" in - azure) - echo "Windows binaries are signed through Azure Artifact Signing (McKinley Rice, Inc.)." - ;; - *) - echo "Windows binaries are signed. Verify the signature with \`signtool verify /pa\`" - echo "or against the provenance attestation below." - ;; - esac - else - echo "**Windows binaries in this release are NOT signed**, so Windows will show a" - echo "SmartScreen warning on first run. Verify the archive against \`SHA256SUMS\`" - echo "and the provenance attestation below before running it." - fi - echo - echo "Every archive carries build provenance attesting it was produced by this" - echo "workflow, which a code signature alone does not establish -- a signature says" - echo "the key was used, not where:" - echo - echo '```sh' - echo "gh attestation verify redrob-darwin-arm64.zip --repo ${{ github.repository }}" - echo '```' - echo - echo "Checksums are in \`SHA256SUMS\`." - } > notes.md git tag -a "$TAG" -m "Redrob Code $TAG (built on opencode v$UPSTREAM)" 2>/dev/null || \ echo "tag $TAG already exists locally" # A release that fails after tagging must be retryable. Without this a single - # transient failure in the steps below leaves the version permanently unusable: + # transient failure in the publish job leaves the version permanently unusable: # the tag exists, so every retry dies on push before reaching the release. # # The tag is named explicitly. `--tags` and `--follow-tags` would push whatever @@ -711,59 +499,7 @@ jobs: # decided to publish ends up published. git push origin "refs/tags/$TAG" 2>/dev/null || echo "tag $TAG is already on the remote" - # The release is created EMPTY and the assets are uploaded one at a time, each - # retried. `gh release create "$TAG" assets/*` uploads all twenty in one command and - # rolls the whole release back when any single one fails, so a transient 5xx on the - # nineteenth asset discards a run that has already spent forty minutes building, - # signing and notarizing. That is not hypothetical: v0.1.0 lost two consecutive - # attempts that way, to `504 Gateway Timeout` and `500 Error creating asset temp dir` - # on different assets. - # - # Splitting it also collapses the create and re-run paths into one: an existing - # release has its notes replaced and its assets clobbered, which is what a retry - # needs anyway. - if gh release view "$TAG" >/dev/null 2>&1; then - echo "::notice::release $TAG exists; replacing its notes and assets" - gh release edit "$TAG" --notes-file notes.md - else - gh release create "$TAG" --title "$TAG" --notes-file notes.md - fi - - failed=() - for asset in assets/*; do - [ -f "$asset" ] || continue - uploaded=false - for attempt in 1 2 3; do - if gh release upload "$TAG" "$asset" --clobber; then - uploaded=true - break - fi - echo "::warning::upload of $(basename "$asset") failed on attempt $attempt" - sleep $((attempt * 10)) - done - if [ "$uploaded" = false ]; then - failed+=("$(basename "$asset")") - fi - done - - if [ ${#failed[@]} -ne 0 ]; then - echo "::error::these assets did not upload after three attempts: ${failed[*]}" - echo "The release and tag exist, so re-running this job resumes rather than restarts." - exit 1 - fi - - # A release missing an archive installs nothing on that platform while looking - # published, so the asset list is checked rather than assumed. - published="$(gh release view "$TAG" --json assets --jq '.assets | length')" - expected="$(find assets -maxdepth 1 -type f | wc -l | tr -d ' ')" - if [ "$published" != "$expected" ]; then - echo "::error::release $TAG has $published assets, expected $expected" - exit 1 - fi - echo "::notice::release $TAG published with $published assets" - - name: Point at the promotion to main - if: ${{ !inputs.dry_run }} env: GH_TOKEN: ${{ github.token }} TAG: ${{ needs.version.outputs.tag }} @@ -777,11 +513,10 @@ jobs: # can approve its own, which is worth more than the convenience of not clicking # a link. So this prints the link and a person opens it. # - # Compared against the remote rather than a local ref: this job checks out - # `develop` alone, so `origin/main` does not exist here and a - # `git log origin/main..HEAD` would come back empty and read as "nothing to - # promote" -- passing for the wrong reason, which is how the previous version of - # this step silently did nothing. + # Compared against the remote rather than a local ref: this job checks out one + # commit, so `origin/main` does not exist here and a `git log origin/main..HEAD` + # would come back empty and read as "nothing to promote" -- passing for the wrong + # reason, which is how the previous version of this step silently did nothing. main_sha="$(git ls-remote origin refs/heads/main | cut -f1)" if [ -z "$main_sha" ]; then echo "::notice::no main branch yet; create it from $TAG to start tracking released state" @@ -795,9 +530,27 @@ jobs: { echo "### Promote \`develop\` to \`main\` for $TAG" echo - echo "The release is published. \`main\` still points at \`${main_sha:0:9}\`, so what" + echo "The release is being published. \`main\` still points at \`${main_sha:0:9}\`, so what" echo "the repository says is *released* has not moved yet." echo echo "[Open the promotion pull request]($url)" } >> "$GITHUB_STEP_SUMMARY" - echo "::notice::release published; promote develop to main at $url" + echo "::notice::promote develop to main at $url" + + # Checksums (`SHA256SUMS.txt`), the launcher manifest (`redrob-launcher.json`) and the release + # itself, one way for every redrob-labs repository. Published as latest; a version carrying a `-` + # is a prerelease. Not on a dry run, and not when any build job failed, was cancelled or skipped. + # The tag is passed explicitly because this workflow runs from a dispatch, not from a tag push. + publish: + needs: [version, build, sign-macos, sign-windows, assets, tag] + if: always() && !inputs.dry_run && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && !contains(needs.*.result, 'skipped') + uses: redrob-labs/.github/.github/workflows/release-publish.yml@main + permissions: + contents: write + with: + version: ${{ needs.version.outputs.version }} + title: Redrob Code + launcher_manifest: true + publish: true + tag: ${{ needs.version.outputs.tag }} + target: ${{ needs.version.outputs.sha }} diff --git a/.github/workflows/tag-hygiene.yml b/.github/workflows/tag-hygiene.yml new file mode 100644 index 0000000000..2e59f43ad4 --- /dev/null +++ b/.github/workflows/tag-hygiene.yml @@ -0,0 +1,68 @@ +name: tag-hygiene + +# This repository's own rule, kept beside the shared gitflow caller (`gitflow.yml`, which only calls +# redrob-labs/.github): no workflow may push tags in bulk or refill refs/tags from upstream. +# +# It lives in its own file because `gitflow.yml` is a verbatim copy of the organization template and +# must stay one. A rule nothing checks is a preference, so this one runs on every pull request. + +on: + pull_request: + # No branch filter. A filter that omits the default branch silently stops running, which is exactly + # what happened in a sibling repository when the default moved to `develop` and three workflows + # kept filtering on `[main, master]`. + types: [opened, reopened, edited, synchronize] + workflow_dispatch: + +permissions: + contents: read + +jobs: + tag-hygiene: + name: no workflow pushes tags in bulk + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + fetch-depth: 1 + + - name: Refuse bulk tag pushes and unnamespaced upstream fetches + run: | + set -euo pipefail + + # This fork inherited over a thousand upstream tags, which buried its own releases and + # made a `v*` glob deletion unusable. They were deleted. Two command shapes would undo + # that in one run, so neither may appear in a workflow: + # + # a tag push with no tag named republishes whatever is in refs/tags + # a tag fetch from `upstream` refills refs/tags from upstream + # + # The release workflow pushes exactly one tag, named in full as `refs/tags/$TAG`, and + # that is the only allowed shape. Fetching OUR OWN tags from `origin` is fine and is not + # matched: the release-tagging actions depend on it. See docs/VERSIONING.md. + # + # Comment lines are stripped before matching. Without that this gate fails on the very + # comment above, which names both banned shapes in order to explain them. + bad=0 + while IFS= read -r file; do + body="$(sed 's/[[:space:]]*#.*$//' "$file")" + + if printf '%s\n' "$body" | grep -nE 'git[[:space:]]+push[^|&;]*--(tags|follow-tags)'; then + echo "::error file=$file::pushes tags in bulk; push one tag by its full refs/tags/" + bad=1 + fi + + # Only upstream's tags are the problem, and only when no refspec redirects them out + # of refs/tags. + if printf '%s\n' "$body" \ + | grep -E 'git[[:space:]]+fetch[^|&;]*--tags[^|&;]*upstream|git[[:space:]]+fetch[^|&;]*upstream[^|&;]*--tags' \ + | grep -qv 'refs/upstream-tags/'; then + echo "::error file=$file::fetches upstream tags into refs/tags; use '+refs/tags/*:refs/upstream-tags/*'" + bad=1 + fi + done < <(find .github/workflows -type f \( -name '*.yml' -o -name '*.yaml' \)) + + if [ "$bad" -ne 0 ]; then + exit 1 + fi + echo "no bulk tag pushes and no unnamespaced upstream tag fetches" diff --git a/AGENTS.md b/AGENTS.md index deb58794c3..e38dd54fbb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,33 +14,32 @@ before editing a package** — `packages/redrob/`, `packages/redrob/test/`, `pac Gitflow. **`develop` is the default branch and the base of every pull request.** -Branch names come from `docs/BRANCHING.md` and nothing else: `feature/`, -`sync/upstream-`, `hotfix/`, `release/`. Do not invent a prefix, and in -particular do not name a branch after the tool or agent that made it: a branch name says what the -change is, not who typed it. - -The table has no name for the back-merge that closes a hotfix, which is part of why that step gets -skipped. Until it does, use `hotfix/-backmerge` and say in the pull request that it carries -`main` into `develop`. - -- **`develop`** integrates. Release from it: the `release` workflow builds, signs, tags, and then - prints a link to open the promotion pull request from `develop` into `main`. +Branch names are `/` with the type from the redrob-labs organization list: +`feat fix chore docs test refactor perf ci build sync hotfix release` (`docs/BRANCHING.md` has the +ones this repository actually uses: `feat/`, `sync/upstream-`, `hotfix/`, +`release/`). Do not invent a prefix, and in particular do not name a branch after the tool +or agent that made it: a branch name says what the change is, not who typed it. + +- **`develop`** integrates. Release from it: the `release` workflow builds, signs, tags, publishes + the release, and prints a link to open the promotion pull request from `develop` into `main`. - **`main`** is released state. It moves through a reviewed pull request, not an automated push. - **Hotfix**: branch from `main`, merge into `main`, release, **then merge `main` back into `develop`**. This is the step that gets skipped. #13 fixed the lockfile on `main`, the back-merge never happened, and for a month `bun install --frozen-lockfile` failed on the default branch. - `branch-sync.yml` now fails on push to `main` while `main` holds a commit `develop` does not, so - this is checked rather than remembered. + The shared gitflow workflow now opens the back-merge pull request (`sync/back-merge-`) on + every push to `main` that leaves `main` ahead of `develop`, so this is checked rather than + remembered. - Both branches are protected: pull requests only, force pushes and deletions blocked, zero required reviews, admin enforcement off. Required checks: `typecheck`, `unit (linux)`, `core (linux)`, `httpapi (linux)`. -Both rules above are enforced by CI, not by memory. `gitflow.yml` runs on every pull request and -fails when the head branch prefix is not one of the four types, because a prefix nobody defined -(`kiro/`) reached this repository while the rule sat in prose. `branch-sync.yml` runs on every push -to `main` and owns the back-merge rule; the two are separate workflows because a branch name can -only be judged once a pull request exists, while a missing back-merge can only be judged after -`main` has moved. +Both rules above are enforced by CI, not by memory. `gitflow.yml` is a verbatim copy of the +organization's caller template and only calls `redrob-labs/.github/.github/workflows/gitflow.yml`, +which owns both checks: `gitflow / branch name follows the convention` on every pull request +(because a prefix nobody defined, `kiro/`, reached this repository while the rule sat in prose) and +`gitflow / main is contained in develop` on every push to `main`. Do not add a type list to the +caller; the one list lives in the organization repository. This repository's own tag rule (no +workflow pushes tags in bulk) is `tag-hygiene.yml`. The invariant, readable by hand: diff --git a/docs/BRANCHING.md b/docs/BRANCHING.md index 5e32d40f05..ab976b6225 100644 --- a/docs/BRANCHING.md +++ b/docs/BRANCHING.md @@ -12,7 +12,7 @@ somewhere people are cloning from. |---|---| | `develop` | **Default.** Integration. Every feature, fix and upstream sync lands here first. | | `main` | The released state. Only ever advanced by a release pull request from `develop`. | -| `feature/` | One change, opened as a pull request into `develop`. | +| `feat/` | One change, opened as a pull request into `develop`. Also `fix/`, `docs/`, `chore/` and the rest of the organization type list, when they describe the change better. | | `sync/upstream-` | An upstream absorption, opened as a pull request into `develop`. | | `hotfix/` | A fix for something already released, into `main` **and** `develop`. | | `release/` | Only when a release needs stabilising before it ships. Usually skipped. | @@ -28,6 +28,11 @@ should not read as the same thing, because they are not. `release/*` is in the table for completeness and is usually a waste of a branch. Reach for it only when a release genuinely needs to stabilise while `develop` keeps moving. +The full type list is the redrob-labs organization's, checked by the shared gitflow workflow on +every pull request: `feat fix chore docs test refactor perf ci build sync hotfix release`. The +caller in `.github/workflows/gitflow.yml` is a verbatim copy of the organization template and +carries no list of its own. + ## Syncing upstream This is the part gitflow has no opinion about, and the part that matters most here. @@ -88,33 +93,43 @@ sorts below the release it precedes. See `docs/VERSIONING.md`. Run the `release` workflow from the Actions tab against `develop` with a `bump` of `patch`, `minor` or `major`. It computes the next version from the newest `v*` tag, builds every platform, signs and notarizes the macOS binaries, signs the Windows binaries and installer -through Azure Artifact Signing when it is configured, attaches build provenance, and creates -the tagged release. -It then prints a link to open the promotion pull request from `develop` into `main`. +through Azure Artifact Signing, attaches build provenance, pushes the annotated tag, and +publishes the release. It then prints a link to open the promotion pull request from +`develop` into `main`. `main` moves through a reviewed pull request rather than an automated push. It is what people believe is released, and it is the one branch where an unreviewed push would be hard to notice. -Pass `dry_run: true` to build, sign and notarize while publishing nothing. That is the way -to check the signing credentials are actually visible to this repository — organization -secrets are not automatically visible to a new public repository, and an absent Azure -configuration degrades to an unsigned Windows binary with a warning rather than failing -the release. +Pass `dry_run: true` to build, sign and notarize while tagging and publishing nothing. That +is the way to check the signing credentials are actually visible to this repository -- +organization secrets are not automatically visible to a new public repository. + +The last two jobs are the organization's, not this repository's. Windows signing is the +shared composite action `redrob-labs/.github/actions/sign-windows`, and the release is the +shared reusable workflow `redrob-labs/.github/.github/workflows/release-publish.yml`, which +collects every `release-*` artifact, writes `SHA256SUMS.txt` and `redrob-launcher.json`, +and publishes `v` as latest (as a prerelease when the version carries a `-`). This +workflow passes it the tag and the commit explicitly, because the tag is created by the +`tag` job here rather than by a push. The release notes are GitHub's generated notes; the +upstream version the build is based on is in the tag message and in `UPSTREAM_VERSION`. +What stays this repository's: version resolution, the Bun build, macOS notarization, the +Linux archives, the Redrob Launcher asset copies (`launcher-assets.ts`), the `VERSION` +marker `install.sh` reads, and build provenance. Windows signing is Azure Artifact Signing authenticated with the job's OpenID Connect token: the `sign-windows` job runs in the `release` environment with `id-token: write`, -and `azure/login` exchanges the token for an Azure credential right before -`script/sign-windows.ps1` runs signtool with the Artifact Signing dlib. The login comes -after the download and immediately before each signing step because the OIDC token lives -minutes. The inputs are the redrob-labs organization secrets `AZURE_CLIENT_ID`, -`AZURE_TENANT_ID`, `AZURE_SUBSCRIPTION_ID` (plain IDs for the login) and -`ARTIFACT_SIGNING_ENDPOINT`, `ARTIFACT_SIGNING_ACCOUNT`, `ARTIFACT_SIGNING_CERT_PROFILE` -(the account to sign with); no certificate file and no client secret exist anywhere. The -three `ARTIFACT_SIGNING_*` names alone decide whether signing happens -- all unset means an -unsigned build with a warning, some set is an error. Every signed file is read back with -`signtool verify /pa` and must be `Valid` with the signer `CN=McKinley Rice, Inc.`; the log -prints ` signed by `. +and `azure/login` exchanges the token for an Azure credential right before the shared +action runs signtool with the Artifact Signing dlib. The login comes after the build and +immediately before each signing step because the OIDC token lives minutes. The inputs are +the redrob-labs organization secrets `AZURE_CLIENT_ID`, `AZURE_TENANT_ID`, +`AZURE_SUBSCRIPTION_ID` (plain IDs for the login) and `ARTIFACT_SIGNING_ENDPOINT`, +`ARTIFACT_SIGNING_ACCOUNT`, `ARTIFACT_SIGNING_CERT_PROFILE` (the account to sign with); no +certificate file and no client secret exist anywhere. The three `ARTIFACT_SIGNING_*` names +alone decide whether signing happens -- all unset means an unsigned build with a warning, +some set is an error. Every signed file is read back with `signtool verify /pa` and must be +`Valid` with the signer `CN=McKinley Rice, Inc.`; the log prints ` signed by CN=...`. +A failed signature fails the job and so the release. ## Hotfixes @@ -122,10 +137,11 @@ Branch from `main`, not `develop`, so the fix does not drag unreleased work with Open the pull request into `main`, release from there, then merge `main` back into `develop` so the fix is not lost on the next release. -That last step is checked, because it is the one that gets skipped. `branch-sync.yml` runs -on every push to `main` and fails while `main` holds a commit `develop` does not, listing -them. A promotion pull request from `develop` leaves the two identical and the check says -nothing; a hotfix reports until it is brought back. +That last step is checked, because it is the one that gets skipped. The shared gitflow +workflow (`gitflow / main is contained in develop`) runs on every push to `main` and, while +`main` holds a commit `develop` does not, opens the back-merge pull request itself as +`sync/back-merge-`. A promotion pull request from `develop` leaves the two identical +and the check says nothing; a hotfix opens the back-merge until it is brought back. The invariant it enforces, which you can read yourself at any time: diff --git a/docs/LOCAL-ENGINE-API.md b/docs/LOCAL-ENGINE-API.md index ffe95fe67f..7a58cca0a3 100644 --- a/docs/LOCAL-ENGINE-API.md +++ b/docs/LOCAL-ENGINE-API.md @@ -145,7 +145,7 @@ step. 2. `$HOME/.redrob/bin/redrob` — where `install.sh` already installs 3. `PATH` 4. absent → the product offers a one-click install that runs the official installer - (GitHub Releases, verified against `SHA256SUMS`) + (GitHub Releases, verified against `SHA256SUMS.txt`) ### One daemon, not one per product diff --git a/script/sign-windows.ps1 b/script/sign-windows.ps1 deleted file mode 100644 index 10d326d520..0000000000 --- a/script/sign-windows.ps1 +++ /dev/null @@ -1,104 +0,0 @@ -<# -.SYNOPSIS - Authenticode-sign files with signtool through Azure Artifact Signing. - -.DESCRIPTION - Reads ARTIFACT_SIGNING_ENDPOINT (e.g. https://krc.codesigning.azure.net), ARTIFACT_SIGNING_ACCOUNT - and ARTIFACT_SIGNING_CERT_PROFILE. The Azure credential is whatever DefaultAzureCredential finds: - in CI that is the `az login` done by azure/login with the job's OpenID Connect token; on a - developer machine, `az login`. There is no certificate file and no client secret anywhere. - - With the three variables unset, it prints a warning and leaves the files unsigned (exit code 0), - so test builds still produce binaries. With only some of them set it fails: a partial - configuration is a mistake, not a choice. - - Every file is read back after signing: `signtool verify /pa /v` and Get-AuthenticodeSignature - must both report a valid chain, and the script prints ` signed by `. - - This is the one place to adapt when the signing setup changes. Optional overrides: - WINDOWS_TIMESTAMP_URL, SIGNTOOL (path to signtool.exe). - -.EXAMPLE - pwsh script/sign-windows.ps1 dist/redrob-windows-x64/bin/redrob.exe dist/redrob-code-x64-setup.exe -#> -param( - [Parameter(Mandatory = $true, ValueFromRemainingArguments = $true)] - [string[]] $Files -) -$ErrorActionPreference = 'Stop' - -function Write-Warn([string] $Message) { - if ($env:GITHUB_ACTIONS) { Write-Output "::warning::$Message" } else { Write-Warning $Message } -} - -function Find-SignTool { - if ($env:SIGNTOOL) { return $env:SIGNTOOL } - $onPath = Get-Command signtool.exe -ErrorAction SilentlyContinue - if ($onPath) { return $onPath.Source } - $kits = Join-Path ${env:ProgramFiles(x86)} 'Windows Kits\10\bin' - $found = Get-ChildItem -Path $kits -Recurse -Filter signtool.exe -ErrorAction SilentlyContinue | - Where-Object { $_.FullName -match '\\x64\\signtool\.exe$' } | - Sort-Object FullName -Descending | Select-Object -First 1 - if (-not $found) { throw 'signtool.exe not found (install the Windows SDK)' } - return $found.FullName -} - -function Invoke-SignTool([string[]] $Arguments) { - & $script:SignTool @Arguments - if ($LASTEXITCODE -ne 0) { throw "signtool $($Arguments[0]) failed with exit code $LASTEXITCODE" } -} - -$Files = @($Files | ForEach-Object { (Resolve-Path $_).Path } | Select-Object -Unique) -$required = 'ARTIFACT_SIGNING_ENDPOINT', 'ARTIFACT_SIGNING_ACCOUNT', 'ARTIFACT_SIGNING_CERT_PROFILE' -$missing = @($required | Where-Object { -not [Environment]::GetEnvironmentVariable($_) }) - -if ($missing.Count -eq $required.Count) { - Write-Warn "Artifact Signing not configured ($($required -join ', ') unset): leaving unsigned: $($Files -join ', ')" - exit 0 -} -if ($missing.Count -gt 0) { throw "Artifact Signing half-configured; missing: $($missing -join ', ')" } - -$script:SignTool = Find-SignTool -$tmp = Join-Path ([IO.Path]::GetTempPath()) "redrob-code-sign-$PID" -New-Item -ItemType Directory -Force -Path $tmp | Out-Null - -try { - Write-Output "Signing with Azure Artifact Signing ($env:ARTIFACT_SIGNING_ACCOUNT / $env:ARTIFACT_SIGNING_CERT_PROFILE): $($Files -join ', ')" - # The signtool dlib from the Microsoft.ArtifactSigning.Client NuGet package. It authenticates with - # DefaultAzureCredential, so the `az login` performed before this script is what it uses. - $pkg = Join-Path $tmp 'artifact-signing-client.zip' - Invoke-WebRequest -Uri 'https://www.nuget.org/api/v2/package/Microsoft.ArtifactSigning.Client' -OutFile $pkg - Expand-Archive -Path $pkg -DestinationPath (Join-Path $tmp 'client') -Force - $dlib = Get-ChildItem -Path (Join-Path $tmp 'client') -Recurse -Filter Azure.CodeSigning.Dlib.dll | - Where-Object { $_.FullName -match '\\x64\\' } | Select-Object -First 1 - if (-not $dlib) { throw 'Azure.CodeSigning.Dlib.dll not found in Microsoft.ArtifactSigning.Client' } - $metadata = Join-Path $tmp 'metadata.json' - @{ - Endpoint = $env:ARTIFACT_SIGNING_ENDPOINT.TrimEnd('/') - CodeSigningAccountName = $env:ARTIFACT_SIGNING_ACCOUNT - CertificateProfileName = $env:ARTIFACT_SIGNING_CERT_PROFILE - # Only the credentials a CI runner or a developer shell can satisfy. Without this list a - # missing login falls through to InteractiveBrowserCredential, which opens a browser nobody - # sees and waits forever. With it, a missing login fails in seconds. - ExcludeCredentials = @( - 'InteractiveBrowserCredential', 'ManagedIdentityCredential', 'SharedTokenCacheCredential', - 'VisualStudioCredential', 'VisualStudioCodeCredential', 'AzurePowerShellCredential', - 'AzureDeveloperCliCredential', 'WorkloadIdentityCredential' - ) - } | ConvertTo-Json | Set-Content -Path $metadata -Encoding utf8 - $ts = if ($env:WINDOWS_TIMESTAMP_URL) { $env:WINDOWS_TIMESTAMP_URL } else { 'http://timestamp.acs.microsoft.com' } - $common = @('sign', '/v', '/fd', 'SHA256', '/td', 'SHA256', '/d', 'Redrob Code', '/du', 'https://github.com/redrob-labs/redrob-code') - Invoke-SignTool ($common + @('/tr', $ts, '/dlib', $dlib.FullName, '/dmdf', $metadata) + $Files) - - # A Public Trust certificate chains to a Microsoft root every Windows trusts, so the full - # Authenticode check must pass. Anything less (an unsigned file, a broken chain, a bad hash) fails. - foreach ($file in $Files) { - Invoke-SignTool @('verify', '/pa', '/v', $file) - $signature = Get-AuthenticodeSignature -FilePath $file - if ($signature.Status -ne 'Valid') { throw "$file signature status $($signature.Status): $($signature.StatusMessage)" } - Write-Output "$file signed by $($signature.SignerCertificate.Subject)" - } -} -finally { - Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue -}