From 90c5396cd1fc80ad4bced329d0d9674d02496d81 Mon Sep 17 00:00:00 2001 From: brady gaster Date: Sat, 3 Oct 2026 16:04:05 -0700 Subject: [PATCH 1/5] fix(gh-aw): install latest stable Squad CLI by default Delegate activation release selection to the SHA-pinned squad-init action and retire obsolete CLI pin automation. Regenerate package consumers and integrity digests; cover source and strict-compiled contracts. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .copilot/skills/release-process/SKILL.md | 4 +- .github/workflows/squad-cli-pin-drift.yml | 163 ------------ .github/workflows/squad-npm-publish.yml | 112 -------- docs/src/content/docs/guide/gh-aw.md | 21 +- scripts/bump-activation-pin.mjs | 175 ------------- test/gh-aw-quality.test.ts | 30 +-- test/squad-cli-latest.test.ts | 67 +++++ test/squad-cli-pin.test.ts | 269 -------------------- test/standalone-release-workflow.test.ts | 16 +- workflows/package/squad-deps-worker.md | 19 +- workflows/package/squad-implement-worker.md | 19 +- workflows/package/squad-retro.md | 19 +- workflows/package/squad.md | 19 +- workflows/shared/squad.md | 27 +- workflows/squad-workflows.manifest.json | 26 +- 15 files changed, 119 insertions(+), 867 deletions(-) delete mode 100644 .github/workflows/squad-cli-pin-drift.yml delete mode 100644 scripts/bump-activation-pin.mjs create mode 100644 test/squad-cli-latest.test.ts delete mode 100644 test/squad-cli-pin.test.ts diff --git a/.copilot/skills/release-process/SKILL.md b/.copilot/skills/release-process/SKILL.md index 3cab4713e..1119f778a 100644 --- a/.copilot/skills/release-process/SKILL.md +++ b/.copilot/skills/release-process/SKILL.md @@ -67,8 +67,8 @@ gh run watch The release workflow rejects stable versions on manual dispatch, creates a GitHub prerelease, publishes npm `preview`, and uploads standalone bundles. It also updates the `squad-preview` Homebrew cask and -`bradygaster.Squad.Preview` WinGet package. The activation pin and insider-tag -promotion remain stable-only. +`bradygaster.Squad.Preview` WinGet package. Activation installs the latest stable +GitHub Release; insider-tag promotion also remains stable-only. ## Insider diff --git a/.github/workflows/squad-cli-pin-drift.yml b/.github/workflows/squad-cli-pin-drift.yml deleted file mode 100644 index 147a1abcc..000000000 --- a/.github/workflows/squad-cli-pin-drift.yml +++ /dev/null @@ -1,163 +0,0 @@ -# Squad standalone release pin drift guard (#1825) -# -# `workflows/shared/squad.md` pins the GitHub Release bundle that new-repo -# activation installs. The pin is usable only when that release carries every -# standalone platform asset and SHA256SUMS.txt. -# -# This job compares the pin against the latest public GitHub Release and verifies -# the exact assets consumed by scripts/install.sh. npm publication is deliberately -# irrelevant: activation downloads the self-contained bundle from GitHub Releases. -# -# On drift it opens an issue rather than only failing, so the alert arrives with a fix -# path attached instead of an unexplained red run. - -name: Squad Standalone Release Pin Drift - -on: - schedule: - # Daily at 07:15 UTC. Off the hour to avoid the scheduler's peak-minute backlog. - - cron: '15 7 * * *' - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: squad-cli-pin-drift - cancel-in-progress: false - -jobs: - check-pin: - name: Compare activation pin against standalone GitHub release - runs-on: ubuntu-latest - permissions: - contents: read - issues: write - steps: - - name: Checkout - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7 - - - name: Compare pin against latest standalone release - id: compare - env: - PIN_FILE: workflows/shared/squad.md - GH_TOKEN: ${{ github.token }} - run: | - set -euo pipefail - - # The pin as activation actually resolves it: the literal fallback in - # `vars.SQUAD_CLI_VERSION || ''`. Extracted with sed rather than a - # PCRE grep because the escaping stays readable, and an extractor nobody can - # read is an extractor nobody will notice has stopped matching. - pinned="$(sed -n "s/.*SQUAD_CLI_VERSION: .*|| '\([^']*\)'.*/\1/p" "$PIN_FILE" | head -1)" - if [ -z "$pinned" ]; then - echo "::error file=$PIN_FILE::Could not locate the SQUAD_CLI_VERSION pin. If the pin moved or changed shape, update this guard — a guard that silently stops finding its target is worse than no guard." - exit 1 - fi - - case "$pinned" in - v*) ;; - *) pinned="v$pinned" ;; - esac - - release_json="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest")" - latest="$(jq -r '.tag_name // empty' <<< "$release_json")" - if [ -z "$latest" ]; then - echo "::error::Could not resolve the latest GitHub Release tag." - exit 1 - fi - - published_assets="$(jq -r '.assets[].name' <<< "$release_json")" - missing="" - for asset in \ - "squad-linux-x64.tar.gz" \ - "squad-linux-arm64.tar.gz" \ - "squad-darwin-x64.tar.gz" \ - "squad-darwin-arm64.tar.gz" \ - "squad-win32-x64.zip" \ - "squad-win32-arm64.zip" \ - "SHA256SUMS.txt"; do - if ! grep -Fxq "$asset" <<< "$published_assets"; then - missing="${missing:+${missing}, }${asset}" - fi - done - - echo "pinned=$pinned" >> "$GITHUB_OUTPUT" - echo "latest=$latest" >> "$GITHUB_OUTPUT" - echo "missing=$missing" >> "$GITHUB_OUTPUT" - echo "Pinned in $PIN_FILE : $pinned" - echo "Latest GitHub Release : $latest" - echo "Missing assets : ${missing:-}" - - if [ "$pinned" = "$latest" ] && [ -z "$missing" ]; then - echo "drift=false" >> "$GITHUB_OUTPUT" - echo "✅ Activation pin matches a complete standalone release." - else - echo "drift=true" >> "$GITHUB_OUTPUT" - echo "::warning::Activation pin ($pinned) does not match a complete latest standalone release ($latest; missing: ${missing:-none})." - fi - - - name: Open drift issue - if: steps.compare.outputs.drift == 'true' - env: - GH_TOKEN: ${{ github.token }} - PINNED: ${{ steps.compare.outputs.pinned }} - LATEST: ${{ steps.compare.outputs.latest }} - MISSING: ${{ steps.compare.outputs.missing }} - TITLE: 'Squad standalone activation release is stale or incomplete' - run: | - set -euo pipefail - - # Only one open issue at a time: this runs daily, and a guard that files a - # duplicate every morning trains people to ignore it. - existing="$(gh issue list --state open --search "$TITLE in:title" --json number --jq '.[0].number // empty')" - if [ -n "$existing" ]; then - echo "Drift issue already open: #$existing — not filing a duplicate." - exit 0 - fi - - # Built with printf rather than a heredoc: heredoc bodies must sit at column 0, - # which terminates the YAML block scalar this script lives in. - # - # shellcheck disable=SC2016 - # SC2016 ("expressions don't expand in single quotes") fires on the markdown - # BACKTICKS below -- shellcheck reads ` as command substitution, and inside - # single quotes it correctly notes it will not expand. That is exactly what we - # want: these are literal code spans in the issue body, not shell expansions. - # Every real value ($PINNED, $LATEST) is passed as a printf ARGUMENT, never - # interpolated into the format string. Scoped to this block, not the file, and - # `test/squad-cli-pin.test.ts` asserts no single-quoted literal here contains a - # `$name` expansion -- so this suppression cannot mask a genuine SC2016 defect. - { - printf '%s\n\n' 'The standalone GitHub Release pinned for new-repo activation is stale or incomplete.' - printf '%s\n' '| | Version |' - printf '%s\n' '|---|---|' - printf '| Pinned in `workflows/shared/squad.md` | `%s` |\n' "$PINNED" - printf '| Latest GitHub Release | `%s` |\n\n' "$LATEST" - printf 'Missing standalone assets: `%s`.\n\n' "${MISSING:-none}" - printf 'New-repo activation downloads `%s` from GitHub Releases; npm publication is not part of this gate.\n\n' "$PINNED" - printf '%s\n\n' '### Fix' - printf 'Publish the complete standalone asset set for `%s`, then update the activation pin if needed:\n\n' "$LATEST" - printf '%s\n' '1. the `Default is .` line in the header comment' - printf '%s\n\n' '2. the `SQUAD_CLI_VERSION` fallback literal in the activation `env:` block' - printf '%s\n\n' '`test/squad-cli-pin.test.ts` fails if those two disagree, so they cannot be updated by halves.' - printf '%s\n' '> Do **not** use npm package metadata as the activation gate. The installer consumes' - printf '%s\n\n' '> GitHub Release assets, and the release must contain every platform bundle plus checksums.' - printf '%s\n' 'Filed automatically by `.github/workflows/squad-cli-pin-drift.yml` (#1825).' - } > "$RUNNER_TEMP/drift-body.md" - - gh issue create --title "$TITLE" --body-file "$RUNNER_TEMP/drift-body.md" - - - name: Fail the run on drift - if: steps.compare.outputs.drift == 'true' - env: - PINNED: ${{ steps.compare.outputs.pinned }} - LATEST: ${{ steps.compare.outputs.latest }} - MISSING: ${{ steps.compare.outputs.missing }} - run: | - # Values pass through env rather than expression interpolation, per the - # shell-input contract in workflows/squad.md: interpolation splices text into - # the script before the shell parses it, so the quoting that would contain a - # hostile value never gets a chance to apply. - echo "::error::Activation pin $PINNED does not match complete release $LATEST (missing: ${MISSING:-none}). See the issue filed by the previous step." - exit 1 diff --git a/.github/workflows/squad-npm-publish.yml b/.github/workflows/squad-npm-publish.yml index 09d529109..2d84cd703 100644 --- a/.github/workflows/squad-npm-publish.yml +++ b/.github/workflows/squad-npm-publish.yml @@ -367,8 +367,6 @@ jobs: contents: read id-token: write outputs: - # Consumed by `bump-activation-pin` so the pin moves to the version this run - # actually published, rather than to whatever npm happens to report later. dist_tag: ${{ steps.version.outputs.dist_tag }} stable_release: ${{ steps.version.outputs.stable_release }} version: ${{ steps.version.outputs.version }} @@ -540,116 +538,6 @@ jobs: echo "::error::Could not verify @bradygaster/squad-cli@${PACKAGE_VERSION} on npm dist-tag '${NPM_DIST_TAG}' after 5 attempts." exit 1 - # --- Issue #1825 ------------------------------------------------------ - # Release-time half of the activation-pin guard. - # - # The literal in `workflows/shared/squad.md` decides which Squad CLI a brand-new - # repository installs during activation. Nothing about publishing used to touch it, - # so every release silently made it stale — it once ran 8 days behind and was caught - # only because someone happened to cold-start a throwaway repo (PR #1818). - # - # `.github/workflows/squad-cli-pin-drift.yml` is the daily backstop that *detects* - # that state. This job is the half that prevents it, by moving the pin in the same - # run that made the new version installable. Both halves are load-bearing: detection - # alone leaves a red build with no fix path, and prevention alone re-breaks the - # moment someone publishes out-of-band. - bump-activation-pin: - name: Bump activation pin to the published release - needs: publish-cli - if: needs.publish-cli.outputs.stable_release == 'true' - runs-on: ubuntu-latest - timeout-minutes: 10 - permissions: - contents: write - pull-requests: write - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7 - with: - # The pin lives on the default branch, but releases are cut from `main`. - # Bumping a `main` checkout would edit a file `dev` never sees, and the next - # merge would quietly restore the stale value. - ref: dev - fetch-depth: 0 - - - name: Confirm the version is published and current - id: target - env: - TARGET: ${{ needs.publish-cli.outputs.version }} - NPM_PACKAGE: '@bradygaster/squad-cli' - run: | - set -euo pipefail - - if [ -z "$TARGET" ]; then - echo "::error::publish-cli reported no version — refusing to guess which version to pin." - exit 1 - fi - - # Two independent sources, which is the whole point of #1825: the version this - # run published, and what npm now serves as `latest`. Checking only the repo - # would compare the pin against itself, and the repo's own manifest holds the - # next *unreleased* version — the E404 that PR #1818 had to fix by hand. - latest="" - for attempt in 1 2 3 4 5; do - latest="$(npm view "$NPM_PACKAGE" dist-tags.latest 2>/dev/null || true)" - if [ "$latest" = "$TARGET" ]; then - break - fi - echo "dist-tags.latest is '${latest:-}', waiting for '$TARGET' (attempt $attempt/5)..." - sleep 15 - done - - if [ "$latest" != "$TARGET" ]; then - echo "::error::npm dist-tags.latest is '${latest:-}', not the published '$TARGET'. Not moving the pin to a version new repositories would not receive." - exit 1 - fi - - echo "version=$TARGET" >> "$GITHUB_OUTPUT" - echo "Confirmed $NPM_PACKAGE@$TARGET is the published latest." - - - name: Rewrite the pin - id: bump - env: - TARGET_VERSION: ${{ steps.target.outputs.version }} - PR_BODY_FILE: ${{ runner.temp }}/activation-pin-pr.md - run: node scripts/bump-activation-pin.mjs - - - name: Open pull request - if: steps.bump.outputs.changed == 'true' - env: - GH_TOKEN: ${{ github.token }} - TARGET: ${{ steps.target.outputs.version }} - PR_BODY_FILE: ${{ runner.temp }}/activation-pin-pr.md - run: | - set -euo pipefail - - # A pull request rather than a push: `dev` does not take direct pushes, and a - # version bump that lands unreviewed is exactly the kind of change that should - # be visible in history. - branch="chore/activation-pin-$TARGET" - - if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then - echo "Branch $branch already exists — an earlier run already opened this bump." - exit 0 - fi - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git checkout -b "$branch" - - # Named explicitly: the checkout is a full release tree, and `git add -A` here - # would sweep in anything the publish jobs left behind. - git add workflows/shared/squad.md docs/src/content/docs/guide/gh-aw.md - git commit -m "chore: pin Squad CLI activation to $TARGET - - Refs #1825" - git push origin "$branch" - - gh pr create \ - --base dev \ - --head "$branch" \ - --title "chore: pin Squad CLI activation to $TARGET" \ - --body-file "$PR_BODY_FILE" - # --- Issues #1491 / #1497 --------------------------------------------- # After a stable release publishes to `latest`, ensure each package's # `insider` dist-tag is not left semver-lower than `latest`. Promotions run diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index 50299fb82..d0c7f2ae2 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -664,17 +664,18 @@ Once the bootstrap PR is merged into the default branch, the `/squad` slash command is live on your repo. Pushing the bootstrap branch or merely opening the PR does not activate the workflow. -### Optional: pin a CLI version +### CLI release selection Activation downloads a self-contained GitHub Release bundle; it does not install -Squad from npm. Set a repository variable to select a specific standalone release: +Squad from npm. The SHA-pinned `squad-init` composite action uses its built-in +default to install the latest stable GitHub Release with mandatory checksum +verification. No repository version variable is required; existing CLI version +variables are no longer read by the shared workflow. -| Variable | Purpose | Default | -|----------|---------|---------| -| `SQUAD_CLI_VERSION` | Standalone GitHub Release tag to install during activation | `v1.0.0` | - -Set it in **Settings → Secrets and variables → Actions → Variables**. A value -without the leading `v` is accepted for compatibility with older configurations. +The CLI release is independent of `SQUAD_SHA`: workflow and action source remain +immutable, while each activation installs the current stable CLI. Direct consumers +can still select a release through the installer's `VERSION` environment variable +or the composite action's optional `version` input; the shared workflow uses neither. ### Optional: enhanced permissions with a GitHub App @@ -2029,7 +2030,7 @@ Understanding the two-job architecture helps when debugging. The activation job runs with full network access: 1. Optionally mints a GitHub App installation token -2. Resolves `SQUAD_CLI_VERSION` (default `v1.0.0`) and downloads the matching +2. Uses the pinned action's latest-release default and downloads the current stable standalone GitHub Release bundle with checksum verification — no npm install 3. Preserves a committed team with roster entries, or runs `squad init --preset default --state-backend local` when no usable team exists @@ -2130,7 +2131,7 @@ other refs. | A Squad label has no description and an unexpected color | It was auto-created on a fresh repo by `create-if-missing` | Expected, not a failure. Edit the label if you want a description or a specific color | | `/squad implement` cannot create a PR | Expected under Profile A (recommended); Actions is not allowed to create pull requests and there is no automatic fallback for this workflow | Push the worker's branch and open the PR manually, or switch to [Profile B](#profile-b-opt-in-automatic-pr-creation) if you accept its repository-wide self-approval tradeoff | | Epic implementation dispatches no workers | Every child is blocked or already has an open implementation PR | Merge dependency PRs, then run `/squad implement` on the epic again | -| Standalone activation fails before init | `SQUAD_CLI_VERSION` is invalid or its release assets are unavailable | Correct the variable or select a published release, then use **Re-run failed jobs** | +| Standalone activation fails before init | Latest stable release lookup, bundle download, or mandatory checksum verification failed | Inspect installer diagnostics and restore complete, valid release assets, then use **Re-run failed jobs** | | Squad health fails | Initialization or committed team state is incomplete | Inspect the `Run Squad health check` JSON, correct the reported state, and rerun; no `squad-state` artifact is uploaded on failure | | A command run was cancelled or its result is uncertain | The run stopped before a durable output was confirmed | Rerun the identical `/squad` command; Cast, implementation, activation, and review paths check existing GitHub state before creating output | diff --git a/scripts/bump-activation-pin.mjs b/scripts/bump-activation-pin.mjs deleted file mode 100644 index 970c4363d..000000000 --- a/scripts/bump-activation-pin.mjs +++ /dev/null @@ -1,175 +0,0 @@ -#!/usr/bin/env node -/** - * Move the Squad CLI activation pin to a published standalone release (#1825). - * - * `.github/workflows/squad-cli-pin-drift.yml` verifies that the pin resolves to a - * complete GitHub Release. This script keeps automated bumps compatible with the - * release-tag format consumed by scripts/install.sh. - * - * It is a script rather than an inline `run:` block for two reasons. The patterns - * below contain backticks (the docs table) and pipes (the YAML `||` fallback), both - * of which are hostile to quoting inside a workflow block scalar — and as a file it - * can be exercised by `test/squad-cli-pin.test.ts` without a release. - * - * Deliberately does NOT read `packages/squad-cli/package.json`. The caller supplies - * a version it has already proven is published. - * - * Fails closed: if any pattern stops matching, the pin has moved and this script has - * silently become a no-op. A bumper that cannot find its target must say so loudly, - * or it reintroduces exactly the silent decay it exists to prevent. - */ - -import { readFileSync, writeFileSync, appendFileSync } from 'node:fs'; - -const PIN_FILE = 'workflows/shared/squad.md'; -const DOCS_FILE = 'docs/src/content/docs/guide/gh-aw.md'; - -/** - * Every place the pinned version is written out. All three must move together: the - * effective pin is what activation installs, and the other two are what a human reads - * when deciding whether the pin is current — a stale copy misinforms precisely the - * person trying to verify it. - * - * None of these patterns contain a literal `${`+`{` sequence, so the file stays safe - * to reference from a workflow without Actions evaluating it as an expression. - */ -const SITES = [ - { - file: PIN_FILE, - label: 'activation env fallback', - pattern: /(SQUAD_CLI_VERSION:[^\n']*\|\|\s*')[^']*(')/g, - }, - { - file: PIN_FILE, - label: 'header comment default', - pattern: /^(#\s+Default is )v?[0-9][^\s.]*(?:\.[^\s.]+)*(\.\s*)$/gm, - }, - { - file: DOCS_FILE, - label: 'docs default column', - pattern: /(\|\s*`SQUAD_CLI_VERSION`[^|\n]*\|[^|\n]*\|\s*`)[^`\n]*(`\s*\|)/g, - }, -]; - -function fail(message) { - console.error(`::error::${message}`); - process.exit(1); -} - -const requestedTarget = (process.env.TARGET_VERSION ?? '').trim(); - -if (!requestedTarget) { - fail('TARGET_VERSION is not set — refusing to guess which version to pin.'); -} - -const target = requestedTarget.startsWith('v') ? requestedTarget : `v${requestedTarget}`; - -// Activation is the cold-start path for brand-new repositories. Keep it on a -// stable GitHub Release tag rather than a prerelease. -if (!/^v\d+\.\d+\.\d+$/.test(target)) { - fail(`refusing to pin a non-stable version: "${requestedTarget}" (expected vMAJOR.MINOR.PATCH)`); -} - -const sources = new Map(); -for (const { file } of SITES) { - if (!sources.has(file)) sources.set(file, readFileSync(file, 'utf8')); -} - -const applied = []; -const dirty = new Set(); - -for (const { file, label, pattern } of SITES) { - const before = sources.get(file); - const matches = [...before.matchAll(pattern)]; - - if (matches.length === 0) { - fail( - `${file}: could not locate the ${label}. The pin moved or changed shape — ` + - 'update this script. A bumper that stops matching becomes a silent no-op, ' + - 'which is worse than no bumper at all.', - ); - } - if (matches.length > 1) { - fail( - `${file}: found ${matches.length} copies of the ${label}, expected exactly 1. ` + - 'Ambiguous targets mean one of them will be left stale.', - ); - } - - const previous = matches[0][0]; - const after = before.replace(pattern, (_full, head, tail) => `${head}${target}${tail}`); - - if (after !== before) { - applied.push({ file, label, previous: previous.trim() }); - dirty.add(file); - } - sources.set(file, after); -} - -// Only rewrite files that actually changed. Running this against the version already -// pinned must be a true no-op, because that is how `test/squad-cli-pin.test.ts` -// exercises the patterns on every pull request — executing the real script is the only -// check that proves all three still match, and it must not touch the working tree. -for (const file of dirty) { - writeFileSync(file, sources.get(file)); -} - -// Re-read from disk and re-extract, rather than trusting the in-memory replace. The -// failure this guards against is a pattern that matched but captured the wrong span, -// which would write a well-formed file pinned to the wrong thing. -const verified = new Map(); -for (const { file, label, pattern } of SITES) { - if (!verified.has(file)) verified.set(file, readFileSync(file, 'utf8')); - const found = [...verified.get(file).matchAll(pattern)][0]?.[0] ?? ''; - if (!found.includes(target)) { - fail(`${file}: the ${label} does not read "${target}" after rewriting: ${found.trim()}`); - } -} - -const changed = applied.length > 0; - -if (changed) { - console.log(`Pinned Squad CLI activation to ${target}:`); - for (const { file, label } of applied) console.log(` - ${file} (${label})`); -} else { - console.log(`Activation pin is already ${target} — nothing to do.`); -} - -// The pull-request body is composed here, not in the workflow. It is markdown, so it -// is full of backticks and pipes; building it with shell printf means every one of -// them is a quoting hazard, and suppressing the resulting shellcheck noise risks -// masking a genuine one. Node writes the file; the workflow only passes its path. -if (changed && process.env.PR_BODY_FILE) { - const rows = applied - .map(({ file, label, previous }) => `| \`${file}\` | ${label} | \`${previous}\` |`) - .join('\n'); - - writeFileSync( - process.env.PR_BODY_FILE, - [ - `Squad standalone release \`${target}\` is published, so new-repo activation should install it.`, - '', - '| File | Site | Was |', - '|---|---|---|', - rows, - '', - 'Opened automatically after publication verification by the activation pin guard', - `for standalone release \`${target}\` (#1825).`, - '', - '> **This pull request has no CI.** GitHub does not fire `pull_request` workflows', - '> for pull requests opened with `GITHUB_TOKEN`. The rewrite was verified in the', - '> job that produced it — `scripts/bump-activation-pin.mjs` re-reads every file it', - '> wrote and fails closed — but the usual checks will not appear below. Close and', - '> reopen this pull request to run them.', - '', - 'If this sits unmerged, `.github/workflows/squad-cli-pin-drift.yml` will file a', - 'drift issue for the same version within a day. That is the intended backstop, not', - 'a duplicate report.', - '', - ].join('\n'), - ); -} - -if (process.env.GITHUB_OUTPUT) { - appendFileSync(process.env.GITHUB_OUTPUT, `changed=${changed}\n`); -} diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index 035363c64..de7cf3aeb 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -1807,23 +1807,22 @@ describe('gh-aw: compiled workflow shell input security contract', () => { ); }, 20000); - it('preserves the standalone release selection in the compiled install step (#1884)', () => { + it('preserves the latest-release default in the compiled install step', () => { const compiled = lockText(); - const pin = readText(join(SHARED_DIR, 'squad.md')).match( - /SQUAD_CLI_VERSION:\s*\$\{\{\s*vars\.SQUAD_CLI_VERSION\s*\|\|\s*'([^']+)'/, - )?.[1]; - - expect(pin, 'could not locate the source Squad CLI fallback').toBeDefined(); - expect(compiled).toMatch( - new RegExp( - String.raw`name: Resolve Squad standalone release[\s\S]*SQUAD_CLI_VERSION:\s*\$\{\{\s*vars\.SQUAD_CLI_VERSION\s*\|\|\s*'${pin}'\s*\}\}`, - ), - ); - expect(compiled).toContain( + const install = compiled.match( + /- name: Install Squad CLI from standalone release[\s\S]*?(?=\n\s+- name:)/, + )?.[0]; + expect(install, 'compiled squad.lock.yml must retain the standalone installer').toContain( 'uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377', ); - expect(compiled).toContain('version: ${{ steps.squad-release.outputs.tag }}'); - expect(compiled).toContain('skip-init: "true"'); + expect(install, 'compiled squad.lock.yml must omit version to use the pinned action latest default') + .not.toMatch(/^\s+version:/m); + expect(install).toContain('skip-init: "true"'); + expect(compiled, 'compiled squad.lock.yml must not contain the obsolete release resolver') + .not.toContain('squad-release'); + expect(compiled).not.toContain('vars.SQUAD_CLI_VERSION'); + expect(compiled).toContain('SQUAD_CLI_VERSION: ${{ steps.squad-cli.outputs.version }}'); + expect(compiled).toContain('squad health --json'); expect(compiled).not.toContain('npm install --global'); expect(compiled).not.toContain('npx --yes "@bradygaster/squad-cli@'); }, 20000); @@ -3397,7 +3396,8 @@ describe('gh-aw: shared bootstrap health-before-dispatch contract (#1605)', () = expect(sharedContent).toContain( 'uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377', ); - expect(sharedContent).toContain('version: ${{ steps.squad-release.outputs.tag }}'); + expect(sharedContent).not.toContain('squad-release'); + expect(sharedContent).toContain('SQUAD_CLI_VERSION: ${{ steps.squad-cli.outputs.version }}'); expect(sharedContent).not.toContain('npm install --global'); expect(sharedContent).not.toContain('npx --yes "@bradygaster/squad-cli@'); }); diff --git a/test/squad-cli-latest.test.ts b/test/squad-cli-latest.test.ts new file mode 100644 index 000000000..d50c0a0f6 --- /dev/null +++ b/test/squad-cli-latest.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect } from 'vitest'; +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parse } from 'yaml'; + +function read(relativePath: string): string { + return readFileSync(join(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n'); +} + +const source = read('workflows/shared/squad.md'); + +describe('Squad standalone activation latest-release default', () => { + it('delegates release selection to the immutable action without an override', () => { + const install = source.match( + /- name: Install Squad CLI from standalone release[\s\S]*?(?=\n\s+- name:)/, + )?.[0]; + + expect(install, 'workflows/shared/squad.md must retain the standalone install step').toBeDefined(); + expect(install).toContain( + 'uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377', + ); + expect(install).toContain("skip-init: 'true'"); + expect(install, 'workflows/shared/squad.md must use the action default, not a version input') + .not.toMatch(/^\s+version:/m); + expect(source, 'workflows/shared/squad.md must not resolve a workflow-level CLI override') + .not.toContain('squad-release'); + expect(source).not.toContain('vars.SQUAD_CLI_VERSION'); + expect(source).not.toMatch(/\bnpm (?:install|ci|view)\b/); + expect(source).toContain('SQUAD_CLI_VERSION: ${{ steps.squad-cli.outputs.version }}'); + expect(source).toContain('squad health --json'); + }); + + it('retains the action latest default and fail-closed checksum installer', () => { + const action = parse(read('.github/actions/squad-init/action.yml')); + const installer = read('scripts/install.sh'); + + expect(action.inputs.version.default).toBe('latest'); + expect(action.outputs.version.value).toBe('${{ steps.install.outputs.version }}'); + expect(installer).toContain('VERSION="${VERSION:-latest}"'); + expect(installer).toContain('/releases/latest'); + expect(installer).toContain('/${VERSION}/SHA256SUMS.txt'); + expect(installer).toContain('err "Checksum download failed: $checksum_url"'); + expect(installer).toContain('err "Checksum mismatch for ${asset}. Expected ${expected}, got ${actual}."'); + }); + + it('does not leave automation that requires or reintroduces the removed CLI pin', () => { + const publish = parse(read('.github/workflows/squad-npm-publish.yml')); + + expect(publish.jobs, 'squad-npm-publish.yml must not rewrite the latest-default activation') + .not.toHaveProperty('bump-activation-pin'); + for (const path of [ + '.github/workflows/squad-cli-pin-drift.yml', + 'scripts/bump-activation-pin.mjs', + ]) { + expect(existsSync(join(process.cwd(), path)), `${path} requires the obsolete CLI literal`).toBe(false); + } + }); + + it('documents latest CLI releases separately from immutable workflow source pins', () => { + const docs = read('docs/src/content/docs/guide/gh-aw.md'); + + expect(docs).not.toContain('`SQUAD_CLI_VERSION`'); + expect(docs).toContain('latest stable GitHub Release'); + expect(docs).toContain('checksum verification'); + expect(docs).toContain('SQUAD_SHA'); + }); +}); diff --git a/test/squad-cli-pin.test.ts b/test/squad-cli-pin.test.ts deleted file mode 100644 index 87f111092..000000000 --- a/test/squad-cli-pin.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Squad standalone release activation pin consistency (#1825) - * - * `workflows/shared/squad.md` states the pinned CLI version twice: once as prose in - * the header comment that documents `vars.SQUAD_CLI_VERSION`, and once as the literal - * fallback that activation actually resolves. Two copies of a version number drift - * against each other, and the prose copy is the one a human reads when deciding - * whether activation is current — so a stale comment misinforms precisely the person - * trying to verify the pin. - * - * This suite is the offline half of the guard. The online half - * (`.github/workflows/squad-cli-pin-drift.yml`) compares the pin against the latest - * complete standalone GitHub Release; it cannot run here because it needs the network, - * and a network call in the unit suite fails closed on an offline machine. - * - * Split that way, each half checks something the other structurally cannot: - * - here: the file agrees with itself, on every PR, deterministically - * - there: the file agrees with reality, daily - * - * Neither reads `packages/squad-cli/package.json`. That can hold the next unreleased - * version, while activation requires a public release carrying standalone assets. - */ - -import { describe, it, expect } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { execFileSync } from 'node:child_process'; -import { join } from 'node:path'; - -const PIN_FILE = join(process.cwd(), 'workflows', 'shared', 'squad.md'); -const DOCS_FILE = join( - process.cwd(), - 'docs', - 'src', - 'content', - 'docs', - 'guide', - 'gh-aw.md', -); -const BUMP_SCRIPT = join(process.cwd(), 'scripts', 'bump-activation-pin.mjs'); -const PUBLISH_WORKFLOW = join( - process.cwd(), - '.github', - 'workflows', - 'squad-npm-publish.yml', -); -const DRIFT_WORKFLOW = join( - process.cwd(), - '.github', - 'workflows', - 'squad-cli-pin-drift.yml', -); - -function readPinFile(): string { - return readFileSync(PIN_FILE, 'utf8'); -} - -/** The literal activation resolves: `vars.SQUAD_CLI_VERSION || ''`. */ -function effectivePin(source: string): string | undefined { - return source.match(/SQUAD_CLI_VERSION:\s*\$\{\{\s*vars\.SQUAD_CLI_VERSION\s*\|\|\s*'([^']+)'/)?.[1]; -} - -/** The version quoted in the header comment that documents the default. */ -function documentedPin(source: string): string | undefined { - return source.match(/^#\s*Default is (v?[0-9][^\s.]*(?:\.[^\s.]+)*)\.\s*$/m)?.[1]; -} - -describe('Squad standalone release activation pin (#1825)', () => { - it('states a resolvable pin', () => { - const pin = effectivePin(readPinFile()); - - // If this stops matching, the drift workflow's extraction has almost certainly - // stopped matching too — and it would report "no drift" against a pin it never - // found. Failing here makes that visible on the PR that reshapes the line. - expect(pin, 'could not locate the SQUAD_CLI_VERSION fallback literal').toBeDefined(); - expect(pin).toMatch(/^v\d+\.\d+\.\d+$/); - }); - - it('documents the same version it resolves', () => { - const source = readPinFile(); - - expect(documentedPin(source), 'header comment does not document a default version').toBeDefined(); - expect(documentedPin(source)).toBe(effectivePin(source)); - }); - - it('normalizes legacy version overrides and never installs through npm', () => { - const source = readPinFile(); - - expect(source).toContain( - 'uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377', - ); - expect(source).toContain('*) release_tag="v${release_tag}" ;;'); - expect(source).toContain("grep -qE '^v[0-9]+\\.[0-9]+\\.[0-9]+$'"); - expect(source).not.toMatch(/\bnpm (?:install|ci|view)\b/); - expect(source).not.toContain('npx --yes'); - }); - - it('binds the selected release to the standalone action gh-aw preserves (#1884)', () => { - const source = readPinFile(); - const resolveStep = source.match( - /- name: Resolve Squad standalone release[\s\S]*?(?=\n\s+- name:)/, - )?.[0]; - const installStep = source.match( - /- name: Install Squad CLI from standalone release[\s\S]*?(?=\n\s+- name:)/, - )?.[0]; - - expect(resolveStep, 'could not locate the Squad release resolver').toBeDefined(); - expect(resolveStep).toMatch( - /env:\s*\n\s+SQUAD_CLI_VERSION:\s*\$\{\{\s*vars\.SQUAD_CLI_VERSION\s*\|\|\s*'[^']+'\s*\}\}/, - ); - expect(installStep, 'could not locate the standalone Squad install step').toContain( - 'uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377', - ); - expect(installStep).toContain('version: ${{ steps.squad-release.outputs.tag }}'); - expect(installStep).toContain("skip-init: 'true'"); - expect(source).not.toMatch( - /activation:\s*\n\s+env:\s*\n\s+SQUAD_CLI_VERSION:/, - ); - }); - - it('keeps the drift guard pointed at complete GitHub Release assets', () => { - const workflow = readFileSync(DRIFT_WORKFLOW, 'utf8'); - - // Comments are stripped first. The header comment names the trap deliberately, to - // explain why the guard avoids it — documenting a hazard should not trip the check - // that enforces it. What must not appear is the path in *executable* lines. - const executable = workflow - .split(/\r?\n/) - .filter((l) => !l.trimStart().startsWith('#')) - .join('\n'); - - // Activation consumes release assets, not a package registry or the next in-repo - // manifest version. - expect(executable).not.toMatch(/packages\/squad-cli\/package\.json/); - expect(executable).not.toMatch(/dist-tags\.latest|npm view/); - expect(executable).toContain('releases/latest'); - expect(executable).toContain('Squad standalone activation release is stale or incomplete'); - for (const asset of [ - 'squad-linux-x64.tar.gz', - 'squad-linux-arm64.tar.gz', - 'squad-darwin-x64.tar.gz', - 'squad-darwin-arm64.tar.gz', - 'squad-win32-x64.zip', - 'squad-win32-arm64.zip', - 'SHA256SUMS.txt', - ]) { - expect(executable).toContain(asset); - } - }); - - it('passes values into shell through env, not expression interpolation', () => { - const workflow = readFileSync(DRIFT_WORKFLOW, 'utf8'); - - // Per the shell-input contract in workflows/squad.md: `${{ }}` is substituted into - // the script text *before* the shell parses it, so quoting cannot contain a hostile - // value — the value has already become code. Only `env:` mapping is safe. - // - // This guard also catches a subtler mistake made while writing this workflow: a - // literal `${{ ... }}` intended as documentation inside an issue body was silently - // evaluated by Actions instead of printed. - const lines = workflow.split(/\r?\n/); - const offenders: string[] = []; - let inRun = false; - let runIndent = 0; - - for (const line of lines) { - const indent = line.length - line.trimStart().length; - if (inRun && line.trim() !== '' && indent <= runIndent) inRun = false; - if (inRun && line.includes('${{')) offenders.push(line.trim()); - if (/^\s*run:\s*\|/.test(line)) { - inRun = true; - runIndent = indent; - } - } - - expect(offenders, `expression interpolation inside run: blocks:\n${offenders.join('\n')}`) - .toEqual([]); - }); - - it('keeps its SC2016 suppression honest', () => { - const workflow = readFileSync(DRIFT_WORKFLOW, 'utf8'); - - // The issue-body builder disables SC2016 because shellcheck reads the markdown - // BACKTICKS in its printf format strings as command substitution and, correctly, - // reports that they will not expand inside single quotes. That is the intended - // behaviour — they are literal code spans in the rendered issue. - // - // But SC2016 also catches a real defect class: writing '$VAR' in single quotes - // while expecting expansion, which silently emits the literal text "$VAR". The - // suppression would hide that too. This test restores exactly that coverage, so - // the disable removes the false positives without removing the check. - // - // Values must reach printf as ARGUMENTS ("$PINNED"), never inside the format string. - const singleQuoted = [...workflow.matchAll(/'([^'\n]*)'/g)].map(m => m[1]); - const offenders = singleQuoted.filter(s => /\$[A-Za-z_{]/.test(s)); - - expect( - offenders, - 'single-quoted literals containing a $expansion — these will emit the literal text, ' + - `not the value, and SC2016 is suppressed here so shellcheck will not say so:\n${offenders.join('\n')}`, - ).toEqual([]); - }); - - it('documents the same version in the published guide', () => { - const pin = effectivePin(readPinFile()); - const docs = readFileSync(DOCS_FILE, 'utf8'); - - // A third copy of the version, in the page that tells people what activation - // installs. It is outside the workflow file, so neither the drift guard's release - // comparison nor the two checks above ever looked at it — it could sit stale - // indefinitely while every other guard reported green. - const documented = docs.match( - /\|\s*`SQUAD_CLI_VERSION`[^|\n]*\|[^|\n]*\|\s*`([^`\n]+)`\s*\|/, - )?.[1]; - - expect(documented, 'gh-aw guide no longer states a SQUAD_CLI_VERSION default').toBeDefined(); - expect(documented).toBe(pin); - }); - - it('can still find every place it has to rewrite', () => { - const pin = effectivePin(readPinFile()); - - // Runs the real bumper against the version already pinned. That is an identity - // rewrite, so it touches nothing — but it exercises all three patterns for real, - // which is the only way to prove they still match. A pattern that quietly stops - // matching turns the release-time bump into a no-op, and the whole failure mode - // #1825 describes is a guard that decays without saying anything. - const env = { ...process.env, TARGET_VERSION: pin as string }; - delete env.GITHUB_OUTPUT; - delete env.PR_BODY_FILE; - - const output = execFileSync(process.execPath, [BUMP_SCRIPT], { - cwd: process.cwd(), - encoding: 'utf8', - env, - }); - - expect(output).toContain('already'); - }); - - it('keeps the existing release bump automation compatible with the standalone pin', () => { - const workflow = readFileSync(PUBLISH_WORKFLOW, 'utf8'); - - // Stable npm publication may still open the pin PR, but the rewrite emits a - // GitHub Release tag. The online drift guard independently rejects missing assets. - expect(workflow).toMatch(/bump-activation-pin:/); - expect(workflow).toMatch(/needs:\s*publish-cli/); - expect(workflow).toContain('scripts/bump-activation-pin.mjs'); - - // The bump must land where the pin lives. Releases are cut from `main`; a bump - // committed there would edit a file the default branch never sees. - expect(workflow).toMatch(/--base dev/); - }); - - it('never derives the pin from the unreleased in-repo manifest', () => { - const script = readFileSync(BUMP_SCRIPT, 'utf8'); - - const executable = script - .split(/\r?\n/) - .filter((l) => { - const t = l.trimStart(); - return !t.startsWith('*') && !t.startsWith('//') && !t.startsWith('/*'); - }) - .join('\n'); - - // The in-repo manifest may be ahead of the standalone GitHub Release. - expect(executable).not.toMatch(/packages\/squad-cli\/package\.json/); - expect(executable).toContain("requestedTarget.startsWith('v')"); - }); -}); diff --git a/test/standalone-release-workflow.test.ts b/test/standalone-release-workflow.test.ts index 2d378fa0b..80ef700a9 100644 --- a/test/standalone-release-workflow.test.ts +++ b/test/standalone-release-workflow.test.ts @@ -258,7 +258,7 @@ describe('reusable npm publication', () => { }); }); - it('uses the requested source ref except for the dev activation pin', () => { + it('uses the requested source ref for publication and promotion', () => { for (const jobName of [ 'preflight', 'smoke-test', @@ -273,10 +273,7 @@ describe('reusable npm publication', () => { expect(checkout?.with?.ref, jobName).toBe('${{ inputs.source_ref || github.ref }}'); } - const activationCheckout = npmPublishWorkflow.jobs['bump-activation-pin'].steps?.find( - (step) => step.uses?.startsWith('actions/checkout@'), - ); - expect(activationCheckout?.with?.ref).toBe('dev'); + expect(npmPublishWorkflow.jobs).not.toHaveProperty('bump-activation-pin'); }); it('builds release-tag workspaces without contributor-only prebuild inputs', () => { @@ -360,9 +357,6 @@ describe('reusable npm publication', () => { expect(verify.run, jobName).toContain('"dist-tags.${NPM_DIST_TAG}"'); } - expect(npmPublishWorkflow.jobs['bump-activation-pin'].if).toBe( - "needs.publish-cli.outputs.stable_release == 'true'", - ); expect(npmPublishWorkflow.jobs['promote-insider-tag-sdk'].if).toBe( "needs.publish-sdk.outputs.stable_release == 'true'", ); @@ -371,7 +365,7 @@ describe('reusable npm publication', () => { ); }); - it('uses least privilege for provenance and the activation pin PR', () => { + it('uses least privilege for publication provenance', () => { for (const jobName of [ 'publish-sdk', 'publish-cli', @@ -384,10 +378,6 @@ describe('reusable npm publication', () => { for (const jobName of ['promote-insider-tag-sdk', 'promote-insider-tag-cli']) { expect(npmPublishWorkflow.jobs[jobName].permissions).toBeUndefined(); } - expect(npmPublishWorkflow.jobs['bump-activation-pin'].permissions).toEqual({ - contents: 'write', - 'pull-requests': 'write', - }); }); }); diff --git a/workflows/package/squad-deps-worker.md b/workflows/package/squad-deps-worker.md index db5c14079..d0d38141b 100644 --- a/workflows/package/squad-deps-worker.md +++ b/workflows/package/squad-deps-worker.md @@ -600,27 +600,10 @@ jobs: app-id: ${{ vars.SQUAD_GITHUB_APP_ID }} private-key: ${{ secrets.SQUAD_GITHUB_APP_PRIVATE_KEY }} owner: ${{ vars.SQUAD_GITHUB_APP_OWNER }} - - name: Resolve Squad standalone release - id: squad-release - env: - SQUAD_CLI_VERSION: ${{ vars.SQUAD_CLI_VERSION || 'v1.0.0' }} - run: | - set -euo pipefail - release_tag="${SQUAD_CLI_VERSION}" - case "${release_tag}" in - v*) ;; - *) release_tag="v${release_tag}" ;; - esac - if ! echo "${release_tag}" | LC_ALL=C grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::SQUAD_CLI_VERSION must be a semver release tag (for example v0.13.1)." - exit 1 - fi - echo "tag=${release_tag}" >> "$GITHUB_OUTPUT" - name: Install Squad CLI from standalone release id: squad-cli uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377 with: - version: ${{ steps.squad-release.outputs.tag }} skip-init: "true" - name: Initialize Squad team env: @@ -658,7 +641,7 @@ jobs: if squad help | grep -Fq 'Validate team state for CI'; then squad health --json else - echo "::warning::Squad CLI ${SQUAD_CLI_VERSION} predates the health command; the readiness gate will activate after the next published CLI pin." + echo "::warning::Squad CLI ${SQUAD_CLI_VERSION} predates the health command; the readiness gate will activate after the next published CLI release." fi - name: Upload Squad state artifact if: success() diff --git a/workflows/package/squad-implement-worker.md b/workflows/package/squad-implement-worker.md index 5912e4ed1..17447fcdc 100644 --- a/workflows/package/squad-implement-worker.md +++ b/workflows/package/squad-implement-worker.md @@ -735,27 +735,10 @@ jobs: app-id: ${{ vars.SQUAD_GITHUB_APP_ID }} private-key: ${{ secrets.SQUAD_GITHUB_APP_PRIVATE_KEY }} owner: ${{ vars.SQUAD_GITHUB_APP_OWNER }} - - name: Resolve Squad standalone release - id: squad-release - env: - SQUAD_CLI_VERSION: ${{ vars.SQUAD_CLI_VERSION || 'v1.0.0' }} - run: | - set -euo pipefail - release_tag="${SQUAD_CLI_VERSION}" - case "${release_tag}" in - v*) ;; - *) release_tag="v${release_tag}" ;; - esac - if ! echo "${release_tag}" | LC_ALL=C grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::SQUAD_CLI_VERSION must be a semver release tag (for example v0.13.1)." - exit 1 - fi - echo "tag=${release_tag}" >> "$GITHUB_OUTPUT" - name: Install Squad CLI from standalone release id: squad-cli uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377 with: - version: ${{ steps.squad-release.outputs.tag }} skip-init: "true" - name: Initialize Squad team env: @@ -793,7 +776,7 @@ jobs: if squad help | grep -Fq 'Validate team state for CI'; then squad health --json else - echo "::warning::Squad CLI ${SQUAD_CLI_VERSION} predates the health command; the readiness gate will activate after the next published CLI pin." + echo "::warning::Squad CLI ${SQUAD_CLI_VERSION} predates the health command; the readiness gate will activate after the next published CLI release." fi - name: Upload Squad state artifact if: success() diff --git a/workflows/package/squad-retro.md b/workflows/package/squad-retro.md index 36301c569..b498be189 100644 --- a/workflows/package/squad-retro.md +++ b/workflows/package/squad-retro.md @@ -720,27 +720,10 @@ jobs: app-id: ${{ vars.SQUAD_GITHUB_APP_ID }} private-key: ${{ secrets.SQUAD_GITHUB_APP_PRIVATE_KEY }} owner: ${{ vars.SQUAD_GITHUB_APP_OWNER }} - - name: Resolve Squad standalone release - id: squad-release - env: - SQUAD_CLI_VERSION: ${{ vars.SQUAD_CLI_VERSION || 'v1.0.0' }} - run: | - set -euo pipefail - release_tag="${SQUAD_CLI_VERSION}" - case "${release_tag}" in - v*) ;; - *) release_tag="v${release_tag}" ;; - esac - if ! echo "${release_tag}" | LC_ALL=C grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::SQUAD_CLI_VERSION must be a semver release tag (for example v0.13.1)." - exit 1 - fi - echo "tag=${release_tag}" >> "$GITHUB_OUTPUT" - name: Install Squad CLI from standalone release id: squad-cli uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377 with: - version: ${{ steps.squad-release.outputs.tag }} skip-init: "true" - name: Initialize Squad team env: @@ -778,7 +761,7 @@ jobs: if squad help | grep -Fq 'Validate team state for CI'; then squad health --json else - echo "::warning::Squad CLI ${SQUAD_CLI_VERSION} predates the health command; the readiness gate will activate after the next published CLI pin." + echo "::warning::Squad CLI ${SQUAD_CLI_VERSION} predates the health command; the readiness gate will activate after the next published CLI release." fi - name: Upload Squad state artifact if: success() diff --git a/workflows/package/squad.md b/workflows/package/squad.md index 208ee76a6..cfb7d47fc 100644 --- a/workflows/package/squad.md +++ b/workflows/package/squad.md @@ -665,27 +665,10 @@ jobs: app-id: ${{ vars.SQUAD_GITHUB_APP_ID }} private-key: ${{ secrets.SQUAD_GITHUB_APP_PRIVATE_KEY }} owner: ${{ vars.SQUAD_GITHUB_APP_OWNER }} - - name: Resolve Squad standalone release - id: squad-release - env: - SQUAD_CLI_VERSION: ${{ vars.SQUAD_CLI_VERSION || 'v1.0.0' }} - run: | - set -euo pipefail - release_tag="${SQUAD_CLI_VERSION}" - case "${release_tag}" in - v*) ;; - *) release_tag="v${release_tag}" ;; - esac - if ! echo "${release_tag}" | LC_ALL=C grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::SQUAD_CLI_VERSION must be a semver release tag (for example v0.13.1)." - exit 1 - fi - echo "tag=${release_tag}" >> "$GITHUB_OUTPUT" - name: Install Squad CLI from standalone release id: squad-cli uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377 with: - version: ${{ steps.squad-release.outputs.tag }} skip-init: "true" - name: Initialize Squad team env: @@ -723,7 +706,7 @@ jobs: if squad help | grep -Fq 'Validate team state for CI'; then squad health --json else - echo "::warning::Squad CLI ${SQUAD_CLI_VERSION} predates the health command; the readiness gate will activate after the next published CLI pin." + echo "::warning::Squad CLI ${SQUAD_CLI_VERSION} predates the health command; the readiness gate will activate after the next published CLI release." fi - name: Upload Squad state artifact if: success() diff --git a/workflows/shared/squad.md b/workflows/shared/squad.md index 783a02a33..e11998091 100644 --- a/workflows/shared/squad.md +++ b/workflows/shared/squad.md @@ -21,10 +21,9 @@ # the App ID is not set. Auth precedence: GitHub App installation token > # SQUAD_GITHUB_TOKEN > github.token. # -# Optional custom Squad CLI version: vars.SQUAD_CLI_VERSION. -# Default is v1.0.0. -# This is a GitHub Release tag whose standalone assets are installed without -# npm; values without a leading `v` are normalized for older configs. +# The SHA-pinned squad-init action installs the latest stable GitHub Release +# using its built-in default and mandatory checksum verification, without npm. +# CLI release selection is independent of the immutable workflow/action pins. # # Optional model: vars.SQUAD_MODEL; omit or use 'auto' for the engine default. # gh-aw resolves aliases with availability fallback. @@ -490,28 +489,10 @@ jobs: private-key: ${{ secrets.SQUAD_GITHUB_APP_PRIVATE_KEY }} owner: ${{ vars.SQUAD_GITHUB_APP_OWNER }} - - name: Resolve Squad standalone release - id: squad-release - env: - SQUAD_CLI_VERSION: ${{ vars.SQUAD_CLI_VERSION || 'v1.0.0' }} - run: | - set -euo pipefail - release_tag="${SQUAD_CLI_VERSION}" - case "${release_tag}" in - v*) ;; - *) release_tag="v${release_tag}" ;; - esac - if ! echo "${release_tag}" | LC_ALL=C grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then - echo "::error::SQUAD_CLI_VERSION must be a semver release tag (for example v0.13.1)." - exit 1 - fi - echo "tag=${release_tag}" >> "$GITHUB_OUTPUT" - - name: Install Squad CLI from standalone release id: squad-cli uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377 with: - version: ${{ steps.squad-release.outputs.tag }} skip-init: 'true' - name: Initialize Squad team @@ -552,7 +533,7 @@ jobs: if squad help | grep -Fq 'Validate team state for CI'; then squad health --json else - echo "::warning::Squad CLI ${SQUAD_CLI_VERSION} predates the health command; the readiness gate will activate after the next published CLI pin." + echo "::warning::Squad CLI ${SQUAD_CLI_VERSION} predates the health command; the readiness gate will activate after the next published CLI release." fi - name: Upload Squad state artifact diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index d8a96720e..ce63c6474 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -14,18 +14,18 @@ "source": "workflows/package/squad.md", "destination": ".github/workflows/squad.md", "lock": ".github/workflows/squad.lock.yml", - "source_sha256": "13410dac23af6144454e29a7fbf697fe7808fd30d2c848a1d3887cc822fdf3f0", - "lock_sha256": "a34fc39cae668ec2dd6fdae615110ec8d270a910b293c6a49bb3b3ab4b175f67", - "package_lock_sha256": "b7d60a96cf9251bfe2ca30274d523618b0495f903bb187518c4e3c0e026732ac" + "source_sha256": "ad17f7de6450f29c99ab556c1baad0a3c6275119227829865f9be4c389b6ac08", + "lock_sha256": "08a678cbd752edf4e4d9c09f260237b5abe2c07f574c57fd997402fec5e340d3", + "package_lock_sha256": "9300fe2ac22bd38782c216e1c0fcd112255e13e3b7a8d5db519f34046d065ad9" }, { "name": "squad-implement-worker", "source": "workflows/package/squad-implement-worker.md", "destination": ".github/workflows/squad-implement-worker.md", "lock": ".github/workflows/squad-implement-worker.lock.yml", - "source_sha256": "8ad749285633009e5f51d5d2bf61f2e9a916a89f4ad652a67948f8242c86c84b", - "lock_sha256": "826e8ae57f71a1feda39cab749dbaba959f3e0f9fbe31248ba60f1134edc35ee", - "package_lock_sha256": "a6832e5cc8b6c5a1c688fdd2e172d479cdf84f1a9edc5cc574595f642126972c" + "source_sha256": "0cd1ff79e5b1befa4f150e9307980c0de46ba559bd36e4b2adf33d320a88b364", + "lock_sha256": "1e5a7c4a139154dcd01d965653a86e68dd33c7eaeecd9552fb2fe44eee2c98b4", + "package_lock_sha256": "edc814614fdc01f7b3683ea6a821eb6b1c4447ec4e593f1a097e1cb942ed02e6" }, { "name": "squad-review", @@ -41,18 +41,18 @@ "source": "workflows/package/squad-deps-worker.md", "destination": ".github/workflows/squad-deps-worker.md", "lock": ".github/workflows/squad-deps-worker.lock.yml", - "source_sha256": "1ff0e1553c25647182b51ea0462c245e1a0b6e24da1de7739e7dbbe8ed9b766b", - "lock_sha256": "238010921a3acae1336c999e23e786d08fbfc343e1c4f69c463f03dd649898ab", - "package_lock_sha256": "04322d836ef81f1512038ef3c5118ea507e82c49325c9d3371db62ea1cc9879e" + "source_sha256": "e751c971faa462a083493fe66dec19198fecd55a819c7f7568cb7f46f2d9d75b", + "lock_sha256": "7500d9e25e7f67d6c6ee64435799d6662d3db715a11e60592b0a4e3b976b1725", + "package_lock_sha256": "05c72ac90a9f5ba537297a9f47dc64bcbbcc7f84a1d79bdbb640c94e6d1b588c" }, { "name": "squad-retro", "source": "workflows/package/squad-retro.md", "destination": ".github/workflows/squad-retro.md", "lock": ".github/workflows/squad-retro.lock.yml", - "source_sha256": "bd788f921eed90bebc16ee18527c457ac51e8e5a32a1295ca7ee0c0fbbf025ce", - "lock_sha256": "f5dfa3fc2000710f2a3d4dd40443c46926d1f09a556e88c2fe1ef9c91cf55e3b", - "package_lock_sha256": "baa17a0f571f2e821e6d204e8b4edf98687da62d95ed6e22923fff20fd6a9367" + "source_sha256": "4c3ff2bda4829c0fa105ad6730fa1e93db091f92abe50108e7bca5cc9dabba5d", + "lock_sha256": "72a6a09df82621e16bafae26f87f03417c9e446c8a17fbab87587873f24ba8b7", + "package_lock_sha256": "075d9a40fdaabd2e7591179a1192bd42b11bd7b6b443d2eca4e764dd664c0fd1" }, { "name": "squad-improvement-worker", @@ -161,7 +161,7 @@ "package_destination": ".github/aw/squad/runtime/shared/squad.md", "destination": ".github/workflows/shared/squad.md", "ownership": "manifest", - "sha256": "8c36f9dd49d5a2e9dcbeaa40f4f38659c427950a362ac1f8efeb6405944860ce" + "sha256": "86f97cffa7f14c9a46d03882fb81059f92ccde9f72514db7db4e87da09391222" }, { "path": "shared/squad-planning-ontology.md", From 2385cf7508ea2651c10d7213da60d41c9a3fba0a Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 4 Oct 2026 09:14:15 +0300 Subject: [PATCH 2/5] fix(gh-aw): align latest-stable workflow contract tests Closes #2167 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- test/gh-aw-quality.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index de7cf3aeb..ce579c1a3 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -3317,12 +3317,15 @@ describe('gh-aw: shared bootstrap health-before-dispatch contract (#1605)', () = expect(sharedContent).toMatch(/health --json/); }); - it('gates health on command capability until the published pin includes it (#1884)', () => { + it('gates health on installed CLI capability and reports missing support (#1884)', () => { expect(sharedContent).toContain( "squad help | grep -Fq 'Validate team state for CI'", ); + expect(sharedContent).toMatch( + /if squad help \| grep -Fq 'Validate team state for CI'; then\s+squad health --json\s+else/, + ); expect(sharedContent).toContain( - 'predates the health command; the readiness gate will activate after the next published CLI pin', + '::warning::Squad CLI ${SQUAD_CLI_VERSION} predates the health command; the readiness gate will activate after the next published CLI release.', ); }); @@ -4001,8 +4004,8 @@ describe('gh-aw: canonical package integrity contract', () => { expect(mutable).not.toContain(actionReference); expect(createHash('sha256').update(normalizeCompiledLock(mutable, revisionA)).digest('hex')) .toBe(sourceBinding === 'workflow' - ? 'e59431b2a2fa9872287449df5cb779c11ce4361206397c181047051446fb9a95' - : '919f1c77ee340bda1ca79d3e1c513fc2ddb2f3d89428133691e2b62699ce71de'); + ? '9de7abefceba5b5ea06d181ae5194cd508559a1706b110a74727a1afc41f74ee' + : '77365f1024ff173dfe61c42d30902deedbf532edd599d5d3b18885e5cfed045c'); expect(() => validateCompilerActionPins(mutable)).toThrow(/invalid immutable action pin/); writeFileSync(lockPath, mutable); expect(verifyInstall(root).failures.join('\n')) @@ -4056,7 +4059,7 @@ describe('gh-aw: canonical package integrity contract', () => { compile(); const unpinned = readText(lockPath); expect(createHash('sha256').update(normalizeCompiledLock(unpinned, revisionA)).digest('hex')) - .toBe('919f1c77ee340bda1ca79d3e1c513fc2ddb2f3d89428133691e2b62699ce71de'); + .toBe('77365f1024ff173dfe61c42d30902deedbf532edd599d5d3b18885e5cfed045c'); expect(verifyInstall(root).failures.join('\n')).toContain('Installed digest mismatch'); const seedPins = () => spawnSync(process.execPath, ['--input-type=module', '-e', seed!], { From 0e881f72e417cf935473b0bf63007f0c76dc647b Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 4 Oct 2026 10:38:52 +0300 Subject: [PATCH 3/5] fix(workflows): bind activation to approved fail-closed installer Pin the npm-free activation action to the maintainer-approved immutable release revision. Exercise the exact pinned action and installer offline, fail closed on checksum download and tampering, and regenerate canonical workflow contracts without weakening existing compiler-pin checks. Closes #2167 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/src/content/docs/guide/gh-aw.md | 4 + test/fixtures/squad-init-approved.json | 16 +++ test/gh-aw-quality.test.ts | 10 +- test/helpers/refresh-squad-init-fixture.mjs | 31 +++++ test/squad-cli-latest.test.ts | 144 ++++++++++++++++++-- workflows/package/squad-deps-worker.md | 2 +- workflows/package/squad-implement-worker.md | 2 +- workflows/package/squad-retro.md | 2 +- workflows/package/squad.md | 2 +- workflows/shared/squad.md | 2 +- workflows/squad-workflows.manifest.json | 26 ++-- 11 files changed, 208 insertions(+), 33 deletions(-) create mode 100644 test/fixtures/squad-init-approved.json create mode 100644 test/helpers/refresh-squad-init-fixture.mjs diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index d0c7f2ae2..513d18a15 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -672,6 +672,10 @@ default to install the latest stable GitHub Release with mandatory checksum verification. No repository version variable is required; existing CLI version variables are no longer read by the shared workflow. +The action runs the installer shipped at its own pinned revision. A failed +checksum download, missing or invalid checksum entry, or checksum mismatch stops +activation before extracting the archive or changing an existing CLI installation. + The CLI release is independent of `SQUAD_SHA`: workflow and action source remain immutable, while each activation installs the current stable CLI. Direct consumers can still select a release through the installer's `VERSION` environment variable diff --git a/test/fixtures/squad-init-approved.json b/test/fixtures/squad-init-approved.json new file mode 100644 index 000000000..e8a33d2d7 --- /dev/null +++ b/test/fixtures/squad-init-approved.json @@ -0,0 +1,16 @@ +{ + "repository": "bradygaster/squad", + "revision": "5c662ba015ec4f99befa51b0e8ca4f2148b0497f", + "files": { + ".github/actions/squad-init/action.yml": { + "gitBlob": "8c73caa756924de659f9cb3b90d8d43adfd56169", + "sha256": "c759c5425c710db59a1a33f4a59f53117a2f2e25c1f0df4e41f27507afd82aa8", + "content": "# Composite action: install Squad from a standalone bundle and initialize a team.\n#\n# Installs the Squad CLI from a GitHub Release asset rather than npm, so this\n# works on runners that cannot reach registry.npmjs.org — self-hosted agents\n# behind a corporate firewall, locked-down build machines, air-gapped mirrors.\n#\n# This is the npm-free replacement for the `npx @bradygaster/squad-cli init`\n# step in the gh-aw activation job (see the limitation documented in #1587,\n# and the RFC in #1593).\n#\n# Usage:\n# - uses: bradygaster/squad/.github/actions/squad-init@\n# with:\n# version: 'v0.11.0' # default: latest release\n# preset: 'default' # default: default\n# state-backend: 'local' # default: local\n#\n# Every input is optional; the defaults match what the gh-aw shared component\n# runs today.\n\nname: 'Squad Init (npm-free)'\ndescription: 'Install Squad from a standalone release bundle and run squad init — no npm registry access required'\n\ninputs:\n version:\n description: 'Squad release tag to install (e.g. v0.11.0). Defaults to the latest release.'\n required: false\n default: 'latest'\n preset:\n description: 'Preset passed to squad init'\n required: false\n default: 'default'\n state-backend:\n description: 'State backend passed to squad init: local, or a backend supported by your Squad version'\n required: false\n default: 'local'\n install-prefix:\n description: 'Install prefix for the bundle. The launcher is linked into /bin.'\n required: false\n default: ''\n repository:\n description: 'Repository to download release assets from. Point this at an internal mirror when github.com is unreachable.'\n required: false\n default: 'bradygaster/squad'\n skip-init:\n description: 'Install the CLI but do not run squad init'\n required: false\n default: 'false'\n working-directory:\n description: 'Directory to run squad init in'\n required: false\n default: '.'\n\noutputs:\n squad-path:\n description: 'Absolute path to the installed squad launcher'\n value: ${{ steps.install.outputs.squad-path }}\n version:\n description: 'Squad version that was installed'\n value: ${{ steps.install.outputs.version }}\n\nruns:\n using: 'composite'\n steps:\n - name: Validate inputs\n shell: bash\n env:\n INPUT_VERSION: ${{ inputs.version }}\n INPUT_PRESET: ${{ inputs.preset }}\n INPUT_BACKEND: ${{ inputs.state-backend }}\n INPUT_REPO: ${{ inputs.repository }}\n INPUT_SKIP_INIT: ${{ inputs.skip-init }}\n run: |\n set -euo pipefail\n # These values reach a shell command below, so constrain them tightly.\n if ! echo \"${INPUT_VERSION}\" | LC_ALL=C grep -qE '^(latest|v?[0-9]+\\.[0-9]+\\.[0-9]+[A-Za-z0-9.-]*)$'; then\n echo \"::error::version must be 'latest' or a semver tag (e.g. v0.11.0)\"\n exit 1\n fi\n for pair in \"preset:${INPUT_PRESET}\" \"state-backend:${INPUT_BACKEND}\"; do\n name=\"${pair%%:*}\"; value=\"${pair#*:}\"\n if ! echo \"${value}\" | LC_ALL=C grep -qE '^[A-Za-z0-9_.-]+$'; then\n echo \"::error::${name} contains invalid characters (only alphanumeric, ., -, _ allowed)\"\n exit 1\n fi\n done\n if ! echo \"${INPUT_REPO}\" | LC_ALL=C grep -qE '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then\n echo \"::error::repository must be in owner/repo form\"\n exit 1\n fi\n if [ \"${INPUT_SKIP_INIT}\" != \"true\" ] && [ \"${INPUT_SKIP_INIT}\" != \"false\" ]; then\n echo \"::error::skip-init must be 'true' or 'false'\"\n exit 1\n fi\n\n - name: Install Squad from release bundle\n id: install\n shell: bash\n env:\n INPUT_VERSION: ${{ inputs.version }}\n INPUT_PREFIX: ${{ inputs.install-prefix }}\n INPUT_REPO: ${{ inputs.repository }}\n ACTION_PATH: ${{ github.action_path }}\n run: |\n set -euo pipefail\n prefix=\"${INPUT_PREFIX:-${HOME}/.squad-standalone}\"\n\n # Run the installer that shipped with this action rather than fetching\n # it from a branch head. Consumers pin this action to a SHA, so the\n # installer is pinned with it — no unpinned remote code execution, and\n # no drift between the installer and the release it is installing.\n #\n # github.action_path is /.github/actions/squad-init when the\n # action is used from a repo reference, so the repo root is three\n # levels up. Resolve it explicitly and verify rather than trusting the\n # relative walk.\n repo_root=\"$(cd \"${ACTION_PATH}/../../..\" && pwd)\"\n installer=\"${repo_root}/scripts/install.sh\"\n if [ ! -f \"${installer}\" ]; then\n echo \"::error::install.sh not found at ${installer}. Resolved repo root '${repo_root}' from action path '${ACTION_PATH}'.\"\n exit 1\n fi\n\n VERSION=\"${INPUT_VERSION}\" PREFIX=\"${prefix}\" REPO=\"${INPUT_REPO}\" \\\n sh \"${installer}\"\n\n squad_bin=\"${prefix}/bin/squad\"\n if [ ! -x \"${squad_bin}\" ]; then\n echo \"::error::squad was not installed at ${squad_bin}\"\n exit 1\n fi\n\n echo \"${prefix}/bin\" >> \"${GITHUB_PATH}\"\n echo \"squad-path=${squad_bin}\" >> \"${GITHUB_OUTPUT}\"\n echo \"version=$(\"${squad_bin}\" --version)\" >> \"${GITHUB_OUTPUT}\"\n\n - name: Verify no npm registry dependency\n shell: bash\n env:\n SQUAD_BIN: ${{ steps.install.outputs.squad-path }}\n run: |\n set -euo pipefail\n # The whole point of this action is that the installed CLI runs without\n # the npm registry. Prove the binary is self-contained before we rely\n # on it, rather than discovering it mid-init.\n \"${SQUAD_BIN}\" --version > /dev/null\n echo \"Squad $(\"${SQUAD_BIN}\" --version) installed and runnable without npm\"\n\n - name: Run squad init\n if: inputs.skip-init != 'true'\n shell: bash\n working-directory: ${{ inputs.working-directory }}\n env:\n SQUAD_BIN: ${{ steps.install.outputs.squad-path }}\n INPUT_PRESET: ${{ inputs.preset }}\n INPUT_BACKEND: ${{ inputs.state-backend }}\n run: |\n set -euo pipefail\n \"${SQUAD_BIN}\" init --preset \"${INPUT_PRESET}\" --state-backend \"${INPUT_BACKEND}\"\n\n - name: Verify npm-free MCP wiring\n if: inputs.skip-init != 'true'\n shell: bash\n working-directory: ${{ inputs.working-directory }}\n env:\n SQUAD_VERSION: ${{ steps.install.outputs.version }}\n run: |\n set -euo pipefail\n if [ ! -f .squad/team.md ]; then\n echo \"::error::squad init completed but .squad/team.md is missing\"\n exit 1\n fi\n\n # An npx-based squad_state entry is unrunnable on the firewalled\n # runners this action exists for, and it fails at Copilot start rather\n # than here — so fail the build now instead of warning.\n if [ -f .mcp.json ] && grep -q '\"npx\"' .mcp.json; then\n echo \"::error::.mcp.json references npx — squad_state will not start without npm registry access. Expected the standalone launcher path.\"\n cat .mcp.json\n exit 1\n fi\n\n {\n echo \"### Squad ${SQUAD_VERSION} initialized\"\n echo \"\"\n echo \"Installed from a standalone release bundle — no npm registry access used.\"\n echo \"\"\n echo \"\\`.mcp.json\\` points at the bundled launcher (no npx).\"\n } >> \"${GITHUB_STEP_SUMMARY}\"\n" + }, + "scripts/install.sh": { + "gitBlob": "4376697dd5993917a9342754a42a683e8d96d0dc", + "sha256": "35713fde7941b64d4e46fb04fbf7dc16d5df9e4d03609f7de86154525c533723", + "content": "#!/bin/sh\n# Squad standalone installer.\n#\n# curl -fsSL https://raw.githubusercontent.com/bradygaster/squad/dev/scripts/install.sh | sh\n#\n# Downloads a self-contained Squad bundle from GitHub Releases. The bundle\n# vendors its own Node.js runtime, so neither Node nor npm needs to be present.\n# Nothing is fetched from registry.npmjs.org.\n#\n# Environment:\n# VERSION Squad version to install (default: latest release)\n# PREFIX Install prefix (default: /usr/local as root, else $HOME/.local)\n# REPO Source repository (default: bradygaster/squad)\n#\n# Mirrors the shape of GitHub Copilot CLI's installer so the two feel the same.\n\nset -eu\n\nREPO=\"${REPO:-bradygaster/squad}\"\nVERSION=\"${VERSION:-latest}\"\n\nif [ -z \"${PREFIX:-}\" ]; then\n if [ \"$(id -u)\" = \"0\" ]; then PREFIX=\"/usr/local\"; else PREFIX=\"$HOME/.local\"; fi\nfi\n\nerr() { printf '\\033[31merror:\\033[0m %s\\n' \"$1\" >&2; exit 1; }\ninfo() { printf '\\033[36m→\\033[0m %s\\n' \"$1\"; }\n\nneed() { command -v \"$1\" >/dev/null 2>&1 || err \"'$1' is required but was not found on PATH.\"; }\nneed tar\n\nif command -v curl >/dev/null 2>&1; then\n fetch() { curl -fsSL \"$1\" -o \"$2\"; }\n fetch_stdout() { curl -fsSL \"$1\"; }\nelif command -v wget >/dev/null 2>&1; then\n fetch() { wget -qO \"$2\" \"$1\"; }\n fetch_stdout() { wget -qO- \"$1\"; }\nelse\n err \"Either curl or wget is required.\"\nfi\n\n# ---------------------------------------------------------------- detect target\nos=\"$(uname -s)\"\ncase \"$os\" in\n Linux) platform=\"linux\" ;;\n Darwin) platform=\"darwin\" ;;\n *) err \"Unsupported operating system: $os. Windows users: install via winget or download the .zip from GitHub Releases.\" ;;\nesac\n\nmachine=\"$(uname -m)\"\ncase \"$machine\" in\n x86_64 | amd64) arch=\"x64\" ;;\n aarch64 | arm64) arch=\"arm64\" ;;\n *) err \"Unsupported architecture: $machine\" ;;\nesac\n\ntarget=\"${platform}-${arch}\"\n\n# ------------------------------------------------------------- resolve version\nif [ \"$VERSION\" = \"latest\" ]; then\n info \"Resolving latest release\"\n VERSION=\"$(fetch_stdout \"https://api.github.com/repos/${REPO}/releases/latest\" \\\n | sed -n 's/.*\"tag_name\"[[:space:]]*:[[:space:]]*\"\\([^\"]*\\)\".*/\\1/p' \\\n | head -n 1)\"\n [ -n \"$VERSION\" ] || err \"Could not resolve the latest release tag for ${REPO}.\"\nfi\n\nasset=\"squad-${target}.tar.gz\"\nurl=\"https://github.com/${REPO}/releases/download/${VERSION}/${asset}\"\n\n# -------------------------------------------------------------------- download\ntmp=\"$(mktemp -d)\"\n# shellcheck disable=SC2064\ntrap \"rm -rf '$tmp'\" EXIT INT TERM\n\ninfo \"Downloading ${asset} (${VERSION})\"\nfetch \"$url\" \"$tmp/$asset\" || err \"Download failed: $url\"\n\n# Checksum verification is mandatory: never install bytes that cannot be\n# matched to the checksum published with the release.\nchecksum_url=\"https://github.com/${REPO}/releases/download/${VERSION}/SHA256SUMS.txt\"\nfetch \"$checksum_url\" \"$tmp/SHA256SUMS.txt\" \\\n || err \"Checksum download failed: $checksum_url\"\n\nmatch_count=\"$(awk -v asset=\"$asset\" '$2 == asset { count++ } END { print count + 0 }' \"$tmp/SHA256SUMS.txt\")\"\n[ \"$match_count\" -eq 1 ] \\\n || err \"Expected exactly one checksum entry for ${asset}; found ${match_count}.\"\nexpected=\"$(awk -v asset=\"$asset\" '$2 == asset { print $1 }' \"$tmp/SHA256SUMS.txt\")\"\nif [ \"${#expected}\" -ne 64 ]; then\n err \"Checksum entry for ${asset} is not a 64-character SHA-256 digest.\"\nfi\ncase \"$expected\" in\n *[!0-9A-Fa-f]*) err \"Checksum entry for ${asset} contains non-hexadecimal characters.\" ;;\nesac\n\nif command -v sha256sum >/dev/null 2>&1; then\n actual=\"$(sha256sum \"$tmp/$asset\" | awk '{print $1}')\"\nelif command -v shasum >/dev/null 2>&1; then\n actual=\"$(shasum -a 256 \"$tmp/$asset\" | awk '{print $1}')\"\nelse\n err \"'sha256sum' or 'shasum' is required to verify the downloaded archive.\"\nfi\nif [ \"$expected\" != \"$actual\" ]; then\n err \"Checksum mismatch for ${asset}. Expected ${expected}, got ${actual}.\"\nfi\ninfo \"Checksum verified\"\n\n# --------------------------------------------------------------------- install\nlibdir=\"${PREFIX}/lib/squad\"\nbindir=\"${PREFIX}/bin\"\n\ninfo \"Installing to ${libdir}\"\nmkdir -p \"$libdir\" \"$bindir\"\nrm -rf \"${libdir:?}/\"*\ntar -xzf \"$tmp/$asset\" -C \"$tmp\"\n# The archive contains a single squad-/ directory.\ncp -R \"$tmp/squad-${target}/.\" \"$libdir/\"\nchmod +x \"$libdir/squad\"\n[ -f \"$libdir/runtime/bin/node\" ] && chmod +x \"$libdir/runtime/bin/node\"\n\nln -sf \"$libdir/squad\" \"$bindir/squad\"\n\nprintf '\\033[32m✓\\033[0m Squad %s installed to %s\\n' \"$VERSION\" \"$bindir/squad\"\n\ncase \":${PATH}:\" in\n *\":${bindir}:\"*) ;;\n *)\n # shellcheck disable=SC2016\n printf '\\n\\033[33m!\\033[0m %s is not on your PATH. Add it with:\\n export PATH=\"%s:$PATH\"\\n' \"$bindir\" \"$bindir\"\n ;;\nesac\n\nif ! command -v copilot >/dev/null 2>&1; then\n printf '\\n\\033[33m!\\033[0m GitHub Copilot CLI was not found on PATH. Squad drives it, so install it too:\\n'\n printf ' curl -fsSL https://gh.io/copilot-install | bash\\n'\nfi\n" + } + } +} diff --git a/test/gh-aw-quality.test.ts b/test/gh-aw-quality.test.ts index ce579c1a3..d5646c387 100644 --- a/test/gh-aw-quality.test.ts +++ b/test/gh-aw-quality.test.ts @@ -1813,7 +1813,7 @@ describe('gh-aw: compiled workflow shell input security contract', () => { /- name: Install Squad CLI from standalone release[\s\S]*?(?=\n\s+- name:)/, )?.[0]; expect(install, 'compiled squad.lock.yml must retain the standalone installer').toContain( - 'uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377', + 'uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f', ); expect(install, 'compiled squad.lock.yml must omit version to use the pinned action latest default') .not.toMatch(/^\s+version:/m); @@ -3397,7 +3397,7 @@ describe('gh-aw: shared bootstrap health-before-dispatch contract (#1605)', () = const healthLine = lines[healthLineIdx]; expect(healthLine, 'health must invoke the installed squad binary').toMatch(/\bsquad health --json/); expect(sharedContent).toContain( - 'uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377', + 'uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f', ); expect(sharedContent).not.toContain('squad-release'); expect(sharedContent).toContain('SQUAD_CLI_VERSION: ${{ steps.squad-cli.outputs.version }}'); @@ -4004,8 +4004,8 @@ describe('gh-aw: canonical package integrity contract', () => { expect(mutable).not.toContain(actionReference); expect(createHash('sha256').update(normalizeCompiledLock(mutable, revisionA)).digest('hex')) .toBe(sourceBinding === 'workflow' - ? '9de7abefceba5b5ea06d181ae5194cd508559a1706b110a74727a1afc41f74ee' - : '77365f1024ff173dfe61c42d30902deedbf532edd599d5d3b18885e5cfed045c'); + ? 'e28d35cd3ae0b920479279c777b26c68a339163a6886794ba82c1c04e28e75d6' + : '7077085c3f31824fbea10ad7e963e10e5cb67819523a1815b9795839fe97fb02'); expect(() => validateCompilerActionPins(mutable)).toThrow(/invalid immutable action pin/); writeFileSync(lockPath, mutable); expect(verifyInstall(root).failures.join('\n')) @@ -4059,7 +4059,7 @@ describe('gh-aw: canonical package integrity contract', () => { compile(); const unpinned = readText(lockPath); expect(createHash('sha256').update(normalizeCompiledLock(unpinned, revisionA)).digest('hex')) - .toBe('77365f1024ff173dfe61c42d30902deedbf532edd599d5d3b18885e5cfed045c'); + .toBe('7077085c3f31824fbea10ad7e963e10e5cb67819523a1815b9795839fe97fb02'); expect(verifyInstall(root).failures.join('\n')).toContain('Installed digest mismatch'); const seedPins = () => spawnSync(process.execPath, ['--input-type=module', '-e', seed!], { diff --git a/test/helpers/refresh-squad-init-fixture.mjs b/test/helpers/refresh-squad-init-fixture.mjs new file mode 100644 index 000000000..a04c8ad1e --- /dev/null +++ b/test/helpers/refresh-squad-init-fixture.mjs @@ -0,0 +1,31 @@ +// Explicit maintenance command: node test/helpers/refresh-squad-init-fixture.mjs +// Fetch only the approved immutable revision; verify GitHub's blob IDs before writing. +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { writeFileSync } from 'node:fs'; + +const revision = '5c662ba015ec4f99befa51b0e8ca4f2148b0497f'; +const files = {}; +for (const path of ['.github/actions/squad-init/action.yml', 'scripts/install.sh']) { + const remote = JSON.parse(execFileSync('gh', [ + 'api', `repos/bradygaster/squad/contents/${path}?ref=${revision}`, + ], { encoding: 'utf8' })); + const bytes = Buffer.from(remote.content, 'base64'); + const blob = createHash('sha1') + .update(`blob ${bytes.length}\0`).update(bytes).digest('hex'); + assert.equal(blob, remote.sha, `GitHub blob mismatch: ${path}`); + files[path] = { + gitBlob: blob, + sha256: createHash('sha256').update(bytes).digest('hex'), + content: bytes.toString('utf8'), + }; +} +writeFileSync('test/fixtures/squad-init-approved.json', `${JSON.stringify({ + repository: 'bradygaster/squad', + revision, + files, +}, null, 2)}\n`); +console.log(JSON.stringify({ revision, files: Object.fromEntries( + Object.entries(files).map(([path, { gitBlob, sha256 }]) => [path, { gitBlob, sha256 }]), +) }, null, 2)); diff --git a/test/squad-cli-latest.test.ts b/test/squad-cli-latest.test.ts index d50c0a0f6..d3a9a03ae 100644 --- a/test/squad-cli-latest.test.ts +++ b/test/squad-cli-latest.test.ts @@ -1,13 +1,100 @@ import { describe, it, expect } from 'vitest'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; import { parse } from 'yaml'; +import { requirePosixShell } from './posix-shell'; function read(relativePath: string): string { return readFileSync(join(process.cwd(), relativePath), 'utf8').replace(/\r\n/g, '\n'); } const source = read('workflows/shared/squad.md'); +const approved = JSON.parse(read('test/fixtures/squad-init-approved.json')); +const revision = '5c662ba015ec4f99befa51b0e8ca4f2148b0497f'; +const actionPath = '.github/actions/squad-init/action.yml'; +const installerPath = 'scripts/install.sh'; +const action = parse(approved.files[actionPath].content); + +function runPinnedInstall(mode: string) { + const scratch = mkdtempSync(join(process.cwd(), '.squad-pinned-install-')); + try { + const write = (path: string, content: string) => { + writeFileSync(join(scratch, path), content, { mode: 0o755 }); + }; + for (const path of ['action/.github/actions/squad-init', 'action/scripts', 'mock-bin', 'downloads', 'prefix/lib/squad']) { + mkdirSync(join(scratch, path), { recursive: true }); + } + write('action/scripts/install.sh', approved.files[installerPath].content); + write('prefix/lib/squad/existing', 'preserve the installed CLI on verification failure'); + write('mock-bin/uname', '#!/bin/sh\ncase "$1" in -s) echo Linux;; -m) echo x86_64;; *) exit 99;; esac\n'); + write('mock-bin/npm', '#!/bin/sh\nexit 99\n'); + const digest = createHash('sha256').update('release archive').digest('hex'); + write('mock-bin/curl', `#!/bin/sh +set -eu +url="$2" +printf '%s\\n' "$url" >> "$PWD/fetches" +case "$url" in + https://api.github.com/repos/bradygaster/squad/releases/latest) + printf '{"tag_name":"v1.0.0"}\\n';; + https://github.com/bradygaster/squad/releases/download/v1.0.0/squad-linux-x64.tar.gz) + printf 'release archive' > "$4";; + https://github.com/bradygaster/squad/releases/download/v1.0.0/SHA256SUMS.txt) + case "$TEST_MODE" in + download-failure) exit 22;; + missing) printf '${digest} squad-darwin-arm64.tar.gz\\n' > "$4";; + duplicate) printf '${digest} squad-linux-x64.tar.gz\\n${digest} squad-linux-x64.tar.gz\\n' > "$4";; + malformed) printf 'not-a-digest squad-linux-x64.tar.gz\\n' > "$4";; + mismatch) printf '${'0'.repeat(64)} squad-linux-x64.tar.gz\\n' > "$4";; + valid) printf '${digest} squad-linux-x64.tar.gz\\n' > "$4";; + *) exit 99;; + esac;; + *) echo "Unexpected network request: $url" >&2; exit 99;; +esac +`); + write('mock-bin/tar', `#!/bin/sh +set -eu +printf 'extracted\\n' >> "$PWD/extractions" +mkdir -p "$4/squad-linux-x64" +printf '#!/bin/sh\\necho 1.0.0\\n' > "$4/squad-linux-x64/squad" +chmod +x "$4/squad-linux-x64/squad" +`); + const install = action.runs.steps.find((step: { id?: string }) => step.id === 'install'); + expect(install).toBeDefined(); + const result = spawnSync(requirePosixShell(), ['-c', ` +export PATH="$PWD/mock-bin:$PATH" +export TMPDIR="$PWD/downloads" +export ACTION_PATH="$PWD/action/.github/actions/squad-init" +export INPUT_PREFIX="$PWD/prefix" +export GITHUB_PATH="$PWD/github-path" +export GITHUB_OUTPUT="$PWD/github-output" +${install.run} +`], { + cwd: scratch, + env: { + ...process.env, + INPUT_VERSION: action.inputs.version.default, + INPUT_REPO: action.inputs.repository.default, + TEST_MODE: mode, + }, + encoding: 'utf8', + timeout: 60_000, + }); + if (result.error) throw result.error; + return { + status: result.status, + output: result.stdout + result.stderr, + extracted: existsSync(join(scratch, 'extractions')), + preserved: existsSync(join(scratch, 'prefix/lib/squad/existing')), + fetches: readFileSync(join(scratch, 'fetches'), 'utf8'), + outputs: existsSync(join(scratch, 'github-output')) + ? readFileSync(join(scratch, 'github-output'), 'utf8') : '', + }; + } finally { + rmSync(scratch, { recursive: true, force: true }); + } +} describe('Squad standalone activation latest-release default', () => { it('delegates release selection to the immutable action without an override', () => { @@ -17,7 +104,7 @@ describe('Squad standalone activation latest-release default', () => { expect(install, 'workflows/shared/squad.md must retain the standalone install step').toBeDefined(); expect(install).toContain( - 'uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377', + `uses: bradygaster/squad/.github/actions/squad-init@${revision}`, ); expect(install).toContain("skip-init: 'true'"); expect(install, 'workflows/shared/squad.md must use the action default, not a version input') @@ -30,19 +117,56 @@ describe('Squad standalone activation latest-release default', () => { expect(source).toContain('squad health --json'); }); - it('retains the action latest default and fail-closed checksum installer', () => { - const action = parse(read('.github/actions/squad-init/action.yml')); - const installer = read('scripts/install.sh'); + it('binds the exact independently retrieved action and installer to the activation pin', () => { + expect(approved.repository).toBe('bradygaster/squad'); + expect(approved.revision).toBe(revision); + const digests = { + [actionPath]: 'c759c5425c710db59a1a33f4a59f53117a2f2e25c1f0df4e41f27507afd82aa8', + [installerPath]: '35713fde7941b64d4e46fb04fbf7dc16d5df9e4d03609f7de86154525c533723', + }; + for (const [path, digest] of Object.entries(digests)) { + const file = approved.files[path]; + const bytes = Buffer.from(file.content); + expect(file.sha256).toBe(digest); + expect(createHash('sha256').update(bytes).digest('hex')).toBe(digest); + expect(createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex')) + .toBe(file.gitBlob); + } expect(action.inputs.version.default).toBe('latest'); expect(action.outputs.version.value).toBe('${{ steps.install.outputs.version }}'); - expect(installer).toContain('VERSION="${VERSION:-latest}"'); - expect(installer).toContain('/releases/latest'); - expect(installer).toContain('/${VERSION}/SHA256SUMS.txt'); - expect(installer).toContain('err "Checksum download failed: $checksum_url"'); - expect(installer).toContain('err "Checksum mismatch for ${asset}. Expected ${expected}, got ${actual}."'); + expect(action.runs.steps.find((step: { id?: string }) => step.id === 'install').run) + .toContain('installer="${repo_root}/scripts/install.sh"'); }); + it('executes the pinned action latest default with a verified platform archive', () => { + const result = runPinnedInstall('valid'); + expect(result.status, result.output).toBe(0); + expect(result.output).toContain('Checksum verified'); + expect(result.extracted).toBe(true); + expect(result.outputs).toContain('version=1.0.0'); + expect(result.fetches.trim().split('\n')).toEqual([ + 'https://api.github.com/repos/bradygaster/squad/releases/latest', + 'https://github.com/bradygaster/squad/releases/download/v1.0.0/squad-linux-x64.tar.gz', + 'https://github.com/bradygaster/squad/releases/download/v1.0.0/SHA256SUMS.txt', + ]); + }, 65_000); + + it.each([ + ['download-failure', 'Checksum download failed'], + ['missing', 'Expected exactly one checksum entry'], + ['duplicate', 'Expected exactly one checksum entry'], + ['malformed', 'not a 64-character SHA-256 digest'], + ['mismatch', 'Checksum mismatch'], + ])('fails closed in the pinned action on %s', (mode, diagnostic) => { + const result = runPinnedInstall(mode); + expect(result.status, result.output).toBe(1); + expect(result.output).toContain(diagnostic); + expect(result.extracted).toBe(false); + expect(result.preserved).toBe(true); + expect(result.outputs).toBe(''); + }, 65_000); + it('does not leave automation that requires or reintroduces the removed CLI pin', () => { const publish = parse(read('.github/workflows/squad-npm-publish.yml')); diff --git a/workflows/package/squad-deps-worker.md b/workflows/package/squad-deps-worker.md index d0d38141b..898db0684 100644 --- a/workflows/package/squad-deps-worker.md +++ b/workflows/package/squad-deps-worker.md @@ -602,7 +602,7 @@ jobs: owner: ${{ vars.SQUAD_GITHUB_APP_OWNER }} - name: Install Squad CLI from standalone release id: squad-cli - uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377 + uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f with: skip-init: "true" - name: Initialize Squad team diff --git a/workflows/package/squad-implement-worker.md b/workflows/package/squad-implement-worker.md index 17447fcdc..32e862cc7 100644 --- a/workflows/package/squad-implement-worker.md +++ b/workflows/package/squad-implement-worker.md @@ -737,7 +737,7 @@ jobs: owner: ${{ vars.SQUAD_GITHUB_APP_OWNER }} - name: Install Squad CLI from standalone release id: squad-cli - uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377 + uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f with: skip-init: "true" - name: Initialize Squad team diff --git a/workflows/package/squad-retro.md b/workflows/package/squad-retro.md index b498be189..c71e1ab66 100644 --- a/workflows/package/squad-retro.md +++ b/workflows/package/squad-retro.md @@ -722,7 +722,7 @@ jobs: owner: ${{ vars.SQUAD_GITHUB_APP_OWNER }} - name: Install Squad CLI from standalone release id: squad-cli - uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377 + uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f with: skip-init: "true" - name: Initialize Squad team diff --git a/workflows/package/squad.md b/workflows/package/squad.md index cfb7d47fc..e0861daf6 100644 --- a/workflows/package/squad.md +++ b/workflows/package/squad.md @@ -667,7 +667,7 @@ jobs: owner: ${{ vars.SQUAD_GITHUB_APP_OWNER }} - name: Install Squad CLI from standalone release id: squad-cli - uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377 + uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f with: skip-init: "true" - name: Initialize Squad team diff --git a/workflows/shared/squad.md b/workflows/shared/squad.md index e11998091..07776be17 100644 --- a/workflows/shared/squad.md +++ b/workflows/shared/squad.md @@ -491,7 +491,7 @@ jobs: - name: Install Squad CLI from standalone release id: squad-cli - uses: bradygaster/squad/.github/actions/squad-init@d8d7ef2d6da93460fecbfd56f8de20f9d10fd377 + uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f with: skip-init: 'true' diff --git a/workflows/squad-workflows.manifest.json b/workflows/squad-workflows.manifest.json index ce63c6474..9653357f8 100644 --- a/workflows/squad-workflows.manifest.json +++ b/workflows/squad-workflows.manifest.json @@ -14,18 +14,18 @@ "source": "workflows/package/squad.md", "destination": ".github/workflows/squad.md", "lock": ".github/workflows/squad.lock.yml", - "source_sha256": "ad17f7de6450f29c99ab556c1baad0a3c6275119227829865f9be4c389b6ac08", - "lock_sha256": "08a678cbd752edf4e4d9c09f260237b5abe2c07f574c57fd997402fec5e340d3", - "package_lock_sha256": "9300fe2ac22bd38782c216e1c0fcd112255e13e3b7a8d5db519f34046d065ad9" + "source_sha256": "1c199b9d97f76a84dbd3e10124d70a70abe3fdf4317670c2fa77ebb9a10b1e0a", + "lock_sha256": "3b5dfcb59cea903a2df3bdd3c9d72547f1edf6577110095fd431a5f92433bf8f", + "package_lock_sha256": "1b8859bb3b7b257e9ef99324c0f13f31604b74f423f614f97603bf59f22a84a3" }, { "name": "squad-implement-worker", "source": "workflows/package/squad-implement-worker.md", "destination": ".github/workflows/squad-implement-worker.md", "lock": ".github/workflows/squad-implement-worker.lock.yml", - "source_sha256": "0cd1ff79e5b1befa4f150e9307980c0de46ba559bd36e4b2adf33d320a88b364", - "lock_sha256": "1e5a7c4a139154dcd01d965653a86e68dd33c7eaeecd9552fb2fe44eee2c98b4", - "package_lock_sha256": "edc814614fdc01f7b3683ea6a821eb6b1c4447ec4e593f1a097e1cb942ed02e6" + "source_sha256": "390e8c6f2614032b4b21113184a3d258d105633a94bff4bdb12d34b4a318bc02", + "lock_sha256": "84f55c7040274ef65e4e22f0a262644599a49f8508f587fb8c228ec76ca6016b", + "package_lock_sha256": "cb222eb5d14d7ec3ecee863ba3f709dc84cb00c952eeeb0fa105bc430e42f6e0" }, { "name": "squad-review", @@ -41,18 +41,18 @@ "source": "workflows/package/squad-deps-worker.md", "destination": ".github/workflows/squad-deps-worker.md", "lock": ".github/workflows/squad-deps-worker.lock.yml", - "source_sha256": "e751c971faa462a083493fe66dec19198fecd55a819c7f7568cb7f46f2d9d75b", - "lock_sha256": "7500d9e25e7f67d6c6ee64435799d6662d3db715a11e60592b0a4e3b976b1725", - "package_lock_sha256": "05c72ac90a9f5ba537297a9f47dc64bcbbcc7f84a1d79bdbb640c94e6d1b588c" + "source_sha256": "825d55fcec5fa1e736e2ddac8d30815e86c8c9fc45f8d2729298669e3130f70d", + "lock_sha256": "e10c2324df670321f5029c61a1508c27274782f67106aabf15577063141c7ecb", + "package_lock_sha256": "45511377b5852617d265a7c6ea49ee02fba253ed8dda7de90369af0d24d36480" }, { "name": "squad-retro", "source": "workflows/package/squad-retro.md", "destination": ".github/workflows/squad-retro.md", "lock": ".github/workflows/squad-retro.lock.yml", - "source_sha256": "4c3ff2bda4829c0fa105ad6730fa1e93db091f92abe50108e7bca5cc9dabba5d", - "lock_sha256": "72a6a09df82621e16bafae26f87f03417c9e446c8a17fbab87587873f24ba8b7", - "package_lock_sha256": "075d9a40fdaabd2e7591179a1192bd42b11bd7b6b443d2eca4e764dd664c0fd1" + "source_sha256": "a30247754ca0d3d2f48f131f617fde48829bd265202575b28f8512b30daeb35d", + "lock_sha256": "6153ee5dd2d5810e7aeb81f2173b672bb61c0d6989642f07d21d69d9111f7eda", + "package_lock_sha256": "addc84da8c083e57d05068b3ae1b8092c3156fee9d7a202eac9bd4aaebaf4b51" }, { "name": "squad-improvement-worker", @@ -161,7 +161,7 @@ "package_destination": ".github/aw/squad/runtime/shared/squad.md", "destination": ".github/workflows/shared/squad.md", "ownership": "manifest", - "sha256": "86f97cffa7f14c9a46d03882fb81059f92ccde9f72514db7db4e87da09391222" + "sha256": "62d48dcaaffcb7ed2c2713e92351a15073f780a252ed7d951bea0ea157778022" }, { "path": "shared/squad-planning-ontology.md", From 50e4b3ad0d6cb3c81fe2723336b8d9ffa5956346 Mon Sep 17 00:00:00 2001 From: Copilot Date: Sun, 4 Oct 2026 11:33:11 +0300 Subject: [PATCH 4/5] fix(tests): honor the pinned action Bash shell Run the approved install step in its declared Bash shell rather than Ubuntu generic sh, enforce POSIX Bash selection, and report premature subprocess status and stderr before reading the download trace. Preserve the approved installer fixture and every checksum-negative assertion. Closes #2167 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- test/squad-cli-latest.test.ts | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/test/squad-cli-latest.test.ts b/test/squad-cli-latest.test.ts index d3a9a03ae..5306eb3f8 100644 --- a/test/squad-cli-latest.test.ts +++ b/test/squad-cli-latest.test.ts @@ -17,6 +17,13 @@ const actionPath = '.github/actions/squad-init/action.yml'; const installerPath = 'scripts/install.sh'; const action = parse(approved.files[actionPath].content); +function pinnedInstallShell(platform: NodeJS.Platform = process.platform): string { + const install = action.runs.steps.find((step: { id?: string }) => step.id === 'install'); + expect(install).toBeDefined(); + expect(install.shell).toBe('bash'); + return platform === 'win32' ? requirePosixShell() : install.shell; +} + function runPinnedInstall(mode: string) { const scratch = mkdtempSync(join(process.cwd(), '.squad-pinned-install-')); try { @@ -62,7 +69,7 @@ chmod +x "$4/squad-linux-x64/squad" `); const install = action.runs.steps.find((step: { id?: string }) => step.id === 'install'); expect(install).toBeDefined(); - const result = spawnSync(requirePosixShell(), ['-c', ` + const result = spawnSync(pinnedInstallShell(), ['--noprofile', '--norc', '-c', ` export PATH="$PWD/mock-bin:$PATH" export TMPDIR="$PWD/downloads" export ACTION_PATH="$PWD/action/.github/actions/squad-init" @@ -82,6 +89,12 @@ ${install.run} timeout: 60_000, }); if (result.error) throw result.error; + if (!existsSync(join(scratch, 'fetches'))) { + throw new Error( + `Pinned action harness stopped before its first download (status=${result.status}, signal=${result.signal}).\n` + + `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`, + ); + } return { status: result.status, output: result.stdout + result.stderr, @@ -134,6 +147,8 @@ describe('Squad standalone activation latest-release default', () => { } expect(action.inputs.version.default).toBe('latest'); + expect(pinnedInstallShell('linux')).toBe('bash'); + expect(pinnedInstallShell('darwin')).toBe('bash'); expect(action.outputs.version.value).toBe('${{ steps.install.outputs.version }}'); expect(action.runs.steps.find((step: { id?: string }) => step.id === 'install').run) .toContain('installer="${repo_root}/scripts/install.sh"'); @@ -152,6 +167,19 @@ describe('Squad standalone activation latest-release default', () => { ]); }, 65_000); + it('reports premature action failure before reading the download trace', () => { + const install = action.runs.steps.find((step: { id?: string }) => step.id === 'install'); + const original = install.run; + try { + install.run = 'printf "harness setup failed\\n" >&2\nexit 2'; + expect(() => runPinnedInstall('valid')).toThrow( + /before its first download \(status=2, signal=null\)[\s\S]*stderr:\nharness setup failed/, + ); + } finally { + install.run = original; + } + }, 65_000); + it.each([ ['download-failure', 'Checksum download failed'], ['missing', 'Expected exactly one checksum entry'], From 584e0b526f77ad7a36d9e13c2da46a2ff417ef06 Mon Sep 17 00:00:00 2001 From: Brady Gaster Date: Thu, 8 Oct 2026 01:29:59 -0700 Subject: [PATCH 5/5] fix(release): delay latest promotion until assets exist Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: d0223600-b85d-432c-ba7b-24ada4558cb1 --- .github/workflows/squad-release.yml | 52 +++++++++++++++++++++++- test/standalone-release-workflow.test.ts | 31 +++++++++++++- 2 files changed, 81 insertions(+), 2 deletions(-) diff --git a/.github/workflows/squad-release.yml b/.github/workflows/squad-release.yml index c065aa8b6..f0bf21dfe 100644 --- a/.github/workflows/squad-release.yml +++ b/.github/workflows/squad-release.yml @@ -170,7 +170,7 @@ jobs: gh release create "${{ steps.version.outputs.tag }}" \ --title "${{ steps.version.outputs.tag }}" \ --generate-notes \ - --latest + --latest=false else gh release create "${{ steps.version.outputs.tag }}" \ --title "${{ steps.version.outputs.tag }}" \ @@ -226,3 +226,53 @@ jobs: upload: true release_tag: ${{ needs.release.outputs.tag }} source_ref: ${{ needs.release.outputs.tag }} + + promote-latest: + name: Promote complete stable release to latest + needs: [release, standalone] + if: needs.release.outputs.created == 'true' && needs.release.outputs.stable_release == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Verify standalone assets and promote latest + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: ${{ needs.release.outputs.tag }} + run: | + set -euo pipefail + required_assets=( + squad-linux-x64.tar.gz + squad-linux-arm64.tar.gz + squad-darwin-x64.tar.gz + squad-darwin-arm64.tar.gz + squad-win32-x64.zip + squad-win32-arm64.zip + SHA256SUMS.txt + ) + mapfile -t published_assets < <( + gh release view "${TAG}" \ + --repo "${GITHUB_REPOSITORY}" \ + --json assets \ + --jq '.assets[].name' + ) + + missing_assets=() + for asset in "${required_assets[@]}"; do + if ! printf '%s\n' "${published_assets[@]}" | grep -Fxq "${asset}"; then + missing_assets+=("${asset}") + fi + done + if [ "${#missing_assets[@]}" -gt 0 ]; then + printf '::error::Release %s is incomplete; refusing latest promotion. Missing: %s\n' \ + "${TAG}" "${missing_assets[*]}" + exit 1 + fi + + gh release edit "${TAG}" --latest --repo "${GITHUB_REPOSITORY}" + latest_tag="$(gh api "repos/${GITHUB_REPOSITORY}/releases/latest" --jq '.tag_name')" + if [ "${latest_tag}" != "${TAG}" ]; then + echo "::error::Latest release is ${latest_tag}; expected ${TAG}." + exit 1 + fi + echo "✅ Complete stable release ${TAG} promoted to latest." diff --git a/test/standalone-release-workflow.test.ts b/test/standalone-release-workflow.test.ts index 80ef700a9..dc72d4b09 100644 --- a/test/standalone-release-workflow.test.ts +++ b/test/standalone-release-workflow.test.ts @@ -113,7 +113,7 @@ describe('standalone release handoff', () => { expect(release).toContain('-preview\\.(0|[1-9][0-9]*)'); expect(release).toContain('Preview releases require the CLI SDK dependency'); expect(release).toContain('--prerelease'); - expect(release).toContain('--latest'); + expect(release).toContain('--latest=false'); expect(releaseWorkflow.jobs.release.outputs?.stable_release).toBe( '${{ steps.version.outputs.stable_release }}', ); @@ -132,6 +132,34 @@ describe('standalone release handoff', () => { expect(release).toContain('source_ref: ${{ needs.release.outputs.tag }}'); }); + it('keeps an incomplete stable release out of latest until standalone assets exist', () => { + const create = stepNamed(releaseWorkflow.jobs.release, 'Create GitHub Release'); + const promotion = releaseWorkflow.jobs['promote-latest']; + const promote = stepNamed(promotion, 'Verify standalone assets and promote latest').run ?? ''; + + expect(create.run).toContain('--latest=false'); + expect(promotion.needs).toEqual(['release', 'standalone']); + expect(promotion.if).toBe( + "needs.release.outputs.created == 'true' && needs.release.outputs.stable_release == 'true'", + ); + for (const asset of [ + 'squad-linux-x64.tar.gz', + 'squad-linux-arm64.tar.gz', + 'squad-darwin-x64.tar.gz', + 'squad-darwin-arm64.tar.gz', + 'squad-win32-x64.zip', + 'squad-win32-arm64.zip', + 'SHA256SUMS.txt', + ]) { + expect(promote).toContain(asset); + } + expect(promote).toContain('refusing latest promotion'); + expect(promote.indexOf('missing_assets[@]')).toBeLessThan( + promote.indexOf('gh release edit "${TAG}" --latest'), + ); + expect(promote).toContain('repos/${GITHUB_REPOSITORY}/releases/latest'); + }); + it('uses only contents permission for the reusable release upload', () => { expect(release).toMatch( /standalone:[\s\S]*?permissions:\r?\n\s+contents: write[\s\S]*?uses: \.\/\.github\/workflows\/squad-standalone-release\.yml/, @@ -566,6 +594,7 @@ describe('automated package publication', () => { for (const permissions of [ releaseWorkflow.permissions, releaseWorkflow.jobs.standalone.permissions, + releaseWorkflow.jobs['promote-latest'].permissions, standaloneWorkflow.permissions, standaloneWorkflow.jobs.publish.permissions, homebrew.permissions,