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/CHANGELOG.md b/CHANGELOG.md index 4c75533f8..58f92de6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.0.1] - 2026-10-03 + +### Fixed + +- `squad health` now accepts casting registry persistent names as valid routing references alongside registry IDs, avoiding false unknown-agent failures (#2165). +- The immutable activation action now binds to an approved installer that fails closed on checksum-download failure, invalid checksum entries, or archive tampering, with offline pinned-installer regression coverage (#2167). + +### Changed + +- Root, SDK, and CLI npm versions advance together to 1.0.1 to satisfy the existing release and npm publication workflows. The CLI SDK dependency floor is now `>=1.0.1`; SDK source is unchanged from v1.0.0. +- Agentic Workflow activation installs the latest stable standalone GitHub Release by default, independently of immutable workflow/action source pins, and no longer rewrites a CLI version pin after publication (#2167). +- Consumed only the CLI health persistent-routing patch changeset. Historical changesets are unchanged; this release does not promote unrelated `dev` changes or republish `Squad.Agents.AI` NuGet 1.0.0. + ## [1.0.0] - 2026-10-02 Squad CLI and SDK (the `@bradygaster/squad-cli` and `@bradygaster/squad-sdk` npm packages) graduate to 1.0.0. This is a stabilization release: there are no breaking API or command changes from 0.13.1. It marks the npm packages as stable per the project's independent per-package versioning policy (distinct from the already-stable `Squad.Agents.AI` NuGet package, which reached 1.0.0 separately in #2143). diff --git a/docs/src/content/docs/guide/gh-aw.md b/docs/src/content/docs/guide/gh-aw.md index ce1703e1a..513d18a15 100644 --- a/docs/src/content/docs/guide/gh-aw.md +++ b/docs/src/content/docs/guide/gh-aw.md @@ -664,17 +664,22 @@ 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 | `v0.13.1` | +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. -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 +2034,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 `v0.13.1`) 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 +2135,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/package-lock.json b/package-lock.json index eda1e07d9..c8375d294 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@bradygaster/squad", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@bradygaster/squad", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "workspaces": [ "packages/*" @@ -8248,11 +8248,11 @@ }, "packages/squad-cli": { "name": "@bradygaster/squad-cli", - "version": "1.0.0", + "version": "1.0.1", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@bradygaster/squad-sdk": ">=1.0.0", + "@bradygaster/squad-sdk": ">=1.0.1", "@modelcontextprotocol/sdk": "^1.30.0", "ink": "^7.1.1", "jsonc-parser": "^3.3.1", @@ -8289,7 +8289,7 @@ }, "packages/squad-sdk": { "name": "@bradygaster/squad-sdk", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@github/copilot-sdk": "^1.0.14", diff --git a/package.json b/package.json index e5f14b42d..a33f34d18 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@bradygaster/squad", - "version": "1.0.0", + "version": "1.0.1", "private": true, "description": "Squad — Programmable multi-agent runtime for GitHub Copilot, built on @github/copilot-sdk", "type": "module", diff --git a/packages/squad-cli/package.json b/packages/squad-cli/package.json index 1e0695949..c8787943f 100644 --- a/packages/squad-cli/package.json +++ b/packages/squad-cli/package.json @@ -1,6 +1,6 @@ { "name": "@bradygaster/squad-cli", - "version": "1.0.0", + "version": "1.0.1", "description": "Squad CLI — Command-line interface for the Squad multi-agent runtime", "type": "module", "bin": { @@ -191,7 +191,7 @@ "node": ">=22.5.0" }, "dependencies": { - "@bradygaster/squad-sdk": ">=1.0.0", + "@bradygaster/squad-sdk": ">=1.0.1", "@modelcontextprotocol/sdk": "^1.30.0", "ink": "^7.1.1", "react": "^19.3.0", diff --git a/packages/squad-cli/src/cli/commands/health.ts b/packages/squad-cli/src/cli/commands/health.ts index 7d0ddb83c..590c21ffa 100644 --- a/packages/squad-cli/src/cli/commands/health.ts +++ b/packages/squad-cli/src/cli/commands/health.ts @@ -286,6 +286,12 @@ function loadKnownRoutingAgentKeys(squadDir: string): Set { throw new Error(`registry entry ${agentId} must be an object`); } keys.add(normalizeAgentRef(agentId)); + if ( + typeof entry.persistent_name === 'string' && + entry.persistent_name.trim() + ) { + keys.add(normalizeAgentRef(entry.persistent_name)); + } } const team = parseTeamMarkdown( diff --git a/packages/squad-sdk/package.json b/packages/squad-sdk/package.json index f898a6d95..857cdd780 100644 --- a/packages/squad-sdk/package.json +++ b/packages/squad-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@bradygaster/squad-sdk", - "version": "1.0.0", + "version": "1.0.1", "description": "Squad SDK — Programmable multi-agent runtime for GitHub Copilot", "type": "module", "main": "./dist/index.js", 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/cli/health-command.test.ts b/test/cli/health-command.test.ts index 8ed67bc51..1a6f3661c 100644 --- a/test/cli/health-command.test.ts +++ b/test/cli/health-command.test.ts @@ -464,6 +464,33 @@ describe('routing readiness', () => { expect(result.message).toContain('registry is invalid'); }); + it('accepts persistent names and registry IDs as agent references', () => { + const registryPath = path.join(squadDir, 'casting', 'registry.json'); + const registry = readFileSync(registryPath, 'utf8') + .replace('"display_name":"Alpha"', '"display_name":"Frontend Lead"') + .replace( + '"persistent_name":"Alpha"', + '"persistent_name":"Frontend Lead"', + ); + writeFileSync(registryPath, registry, 'utf8'); + writeSquad('team.md', TEAM.replace('| Alpha |', '| Frontend Lead |')); + writeSquad( + path.join('agents', 'alpha', 'charter.md'), + CHARTER.replace('**Name:** Alpha', '**Name:** Frontend Lead'), + ); + writeSquad( + 'routing.md', + ROUTING.replace( + '| feature | Alpha | New work |', + '| feature | Frontend Lead | New work |\n| maintenance | alpha | Maintenance |', + ), + ); + + expect(check(runSquadHealth(squadDir, repoRoot), 'routing').status).toBe( + 'pass', + ); + }); + it('reports unknown agents deterministically', () => { writeSquad( 'routing.md', 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 744e427aa..842ff0666 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*\}\}`, - ), + 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@5c662ba015ec4f99befa51b0e8ca4f2148b0497f', ); - expect(compiled).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); @@ -3318,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.', ); }); @@ -3395,9 +3397,10 @@ 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).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@'); }); @@ -4010,8 +4013,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' - ? 'c86cd65d09ee74d8da4dfec27f64ccba818d22f282c5a4acf33f04e04f5d6bd6' - : '206fcc936e5c03e81928565104bc7b2fef9c492f9e8152b17df563bd07e5065a'); + ? 'e28d35cd3ae0b920479279c777b26c68a339163a6886794ba82c1c04e28e75d6' + : '7077085c3f31824fbea10ad7e963e10e5cb67819523a1815b9795839fe97fb02'); expect(() => validateCompilerActionPins(mutable)).toThrow(/invalid immutable action pin/); writeFileSync(lockPath, mutable); expect(verifyInstall(root).failures.join('\n')) @@ -4065,7 +4068,7 @@ describe('gh-aw: canonical package integrity contract', () => { compile(); const unpinned = readText(lockPath); expect(createHash('sha256').update(normalizeCompiledLock(unpinned, revisionA)).digest('hex')) - .toBe('206fcc936e5c03e81928565104bc7b2fef9c492f9e8152b17df563bd07e5065a'); + .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 new file mode 100644 index 000000000..5306eb3f8 --- /dev/null +++ b/test/squad-cli-latest.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect } from 'vitest'; +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 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 { + 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(pinnedInstallShell(), ['--noprofile', '--norc', '-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; + 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, + 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', () => { + 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@${revision}`, + ); + 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('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(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"'); + }); + + 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('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'], + ['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')); + + 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 5ef612717..42e48ba30 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 68f1ddd72..898db0684 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 || 'v0.13.1' }} - 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 + uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f 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 b05bb05dd..32e862cc7 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 || 'v0.13.1' }} - 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 + uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f 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 117481230..c71e1ab66 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 || 'v0.13.1' }} - 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 + uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f 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 7aea66ac1..e0861daf6 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 || 'v0.13.1' }} - 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 + uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f 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 2b45d4de6..07776be17 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 v0.13.1. -# 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 || 'v0.13.1' }} - 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 + uses: bradygaster/squad/.github/actions/squad-init@5c662ba015ec4f99befa51b0e8ca4f2148b0497f 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 fcef213ab..bfdf427fc 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": "450e9d3d7ef1b173cedd054d89e72700d8545406bd1fe0acd813d5ed76e04e91", - "lock_sha256": "c5d6e1b7a42e7f4b7dd748ae59c762d08acba9f20e0d6328290eec72e5358717", - "package_lock_sha256": "d0688be3e9492288091c103bd55d6e2845f4541f9e6915db2d4a84e488d4b763" + "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": "40067547110aa4c3186091265a2d77a1f05b8e1c0cca92a274470838d12e1ec3", - "lock_sha256": "11610f64c32403acb69b8f111f0fe584572a0e715bcb53eccaa5bd0cacaa57b5", - "package_lock_sha256": "c6b34a8ae8e9848bbc964a0ca73080ab9c06a9b9238de860954f84f0ba1bf5d7" + "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": "8057ff2e4cb2a6be55da43092397a890880fb94cadfe8d0dd15766dd22f1fe9e", - "lock_sha256": "31ece8146cf21ca5aec2880fd5daa01b982f037a961d3fd0b7c50a6eb3893dd4", - "package_lock_sha256": "59f035683a2b2dbecabdb685ff8280b05c4a081cf3d934a1f77591902b8e83a1" + "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": "8d7e1fdd5fa337da8208832b1cc7c6dbfd253dde2c1b45a7f68a805eb9025620", - "lock_sha256": "41fe367f5391eea781a0045adff8cb5039a689123b5b1798367a0078e19073e9", - "package_lock_sha256": "19933818ea4a2a89040e9e12706f80b56e6b6d6e53925f92c66f88d99676184f" + "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": "4cddc662ecd98f9fcd8e8623e908a2bd1a75c30c12bbcf98e3ad26db93135a66" + "sha256": "62d48dcaaffcb7ed2c2713e92351a15073f780a252ed7d951bea0ea157778022" }, { "path": "shared/squad-planning-ontology.md",