Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"plugins": [
{
"name": "vstack",
"version": "1.46.0",
"version": "1.47.0",
"source": "./claude",
"description": "28 skills that fire without a slash command, 14 agents, 15 commands, and the session hook that routes situations to skills. Most skills are ported from pstack and Superpowers — see claude/skills/ATTRIBUTION.md for per-skill source and license.",
"category": "workflow"
Expand Down
249 changes: 238 additions & 11 deletions .claude/verify.sh

Large diffs are not rendered by default.

63 changes: 63 additions & 0 deletions .github/scripts/should-delete-candidate-tag.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/usr/bin/env bash
# should-delete-candidate-tag.sh — decides whether a candidate release tag has earned deletion.
#
# This decision used to live entirely in a GitHub Actions `if:` expression on the
# cleanup-on-failed-gate job. An `if:` expression cannot be run, so the one piece of this
# workflow that destroys something -- it force-deletes a tag from origin -- was the only piece
# with no test. It has already been wrong once in production: on 2026-08-27 it deleted the tag
# for a gate that was UNDECIDED rather than failed, which deadlocked releases, because verify
# cannot go green until the tag is on origin and the tag could not survive long enough for
# verify to finish.
#
# The rule, stated once so both the workflow and its test read the same sentence:
#
# Delete when a required job has DECIDED against this tag. Keep when nothing has decided yet.
#
# "Undecided" is the only carve-out and it is deliberately narrow. Every other refusal -- a
# decided failure, a bad tag name, a commit that is not an ancestor of main, a gate step that
# died before it could set its verdict -- still deletes, so "a failed required job cannot
# produce a published tag" holds exactly as it did before the carve-out existed.
#
# Usage: should-delete-candidate-tag.sh <resolve-result> <gate-verdict> <matrix-result>
# resolve-result the `needs.resolve.result` string: success | failure | cancelled | skipped
# gate-verdict the `needs.resolve.outputs.gate` string: green | failed | undecided | ""
# matrix-result the `needs.container-matrix.result` string
#
# Exit codes, distinct on purpose. 1 is reserved for "this script broke", so a crash can never
# be mistaken for either verdict:
# 0 DELETE -- something decided against this tag
# 10 KEEP -- nothing has decided against it yet
# 2 usage error
set -euo pipefail

if [ "$#" -ne 3 ]; then
echo "usage: should-delete-candidate-tag.sh <resolve-result> <gate-verdict> <matrix-result>" >&2
exit 2
fi

resolve="$1"
gate="$2"
matrix="$3"

# The container matrix has no undecided state to carve out: it either ran the images or it did
# not, and a failure there is a decision. Checked first because it is unconditional.
if [ "$matrix" = failure ]; then
echo "DELETE container-matrix failed -- the images this tag claims to support did not build"
exit 0
fi

if [ "$resolve" = failure ]; then
if [ "$gate" = undecided ]; then
# The whole reason this script exists. A gate that has not been answered has not answered NO.
echo "KEEP resolve failed but the gate is undecided -- nothing has decided against this tag, and deleting on 'not yet' deadlocks the release"
exit 10
fi
# Includes gate=failed, gate=green-but-a-later-step-failed, and gate="" for a job that died
# before the gate step ran. Conservative direction: anything that is not specifically "not
# yet" is treated as a decision.
echo "DELETE resolve failed with gate='${gate:-<unset>}' -- a required check decided against this tag, or the job died before it could say otherwise"
exit 0
fi

echo "KEEP resolve=${resolve} matrix=${matrix} -- no required job failed"
exit 10
26 changes: 25 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,38 @@ jobs:
# Every other refusal -- a decided failure, a bad tag name, a commit that is not an ancestor
# of main, a gate step that never ran -- still deletes, so "a failed required job cannot
# produce a tag" holds exactly as before.
if: always() && ((needs.resolve.result == 'failure' && needs.resolve.outputs.gate != 'undecided') || needs.container-matrix.result == 'failure')
# Deliberately BROADER than the delete rule. This condition only decides whether the job
# gets a runner; .github/scripts/should-delete-candidate-tag.sh decides whether anything is
# deleted, and check 51 asserts this expression carries no part of that rule. An `if:`
# expression cannot be executed by a test, so the one destructive step in this workflow had
# the one decision nobody could exercise -- and it was wrong in production on 2026-08-27.
# Broad here is safe: a job that runs and then declines to delete costs a runner minute. A
# condition narrower than the script would silently skip deletions the rule requires.
if: always() && (needs.resolve.result != 'success' || needs.container-matrix.result != 'success')
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Has anything actually decided against this tag?
id: decide
run: |
set +e
bash .github/scripts/should-delete-candidate-tag.sh \
"${{ needs.resolve.result }}" \
"${{ needs.resolve.outputs.gate }}" \
"${{ needs.container-matrix.result }}"
rc=$?
set -e
case "$rc" in
0) echo "delete=yes" >> "$GITHUB_OUTPUT" ;;
10) echo "delete=no" >> "$GITHUB_OUTPUT" ;;
*) echo "should-delete-candidate-tag.sh exited $rc, which is neither verdict; refusing to guess"; exit 1 ;;
esac

- name: Delete the candidate tag; it did not earn publication
if: steps.decide.outputs.delete == 'yes'
run: |
set -euo pipefail
TAG="${{ github.event.inputs.tag || github.ref_name }}"
Expand Down
31 changes: 29 additions & 2 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -311,8 +311,29 @@ jobs:
- name: Install matrix on macOS
run: ./tests/install-matrix.sh

# check 29 ("shellcheck clean") skips without a validator on PATH, and a skip is not a pass.
# macos-latest stopped shipping shellcheck, so this lane silently stopped linting all 71
# scripts on the one platform whose BSD tools this job exists to exercise. Installed here
# rather than added to the approved-skip list below, for the same reason the alpine lane
# installs it: approving the skip would make the check disappear and the lane still green.
- name: shellcheck for macOS, or fail naming it
run: |
brew install shellcheck
shellcheck --version

# NOT piped into tee. Under this workflow's default `bash -e` shell there is no pipefail, so
# `verify.sh | tee` returns tee's status and a red gate exits 0. Measured on 2026-08-27:
# `bash -e -c 'red-gate | tee f'` exits 0, the same command unpiped exits 1, and the skip
# audit below exits 0 on a log full of FAIL lines because it only reads skip lines. A failing
# gate on this lane has therefore never failed this job. The redirect keeps the log for the
# skip audit, `|| rc=$?` keeps `-e` from exiting before the code is read, and the exit is on
# its own line where it can be seen.
- name: Run the verification gate
run: ./.claude/verify.sh | tee "$RUNNER_TEMP/gate-macos.txt"
run: |
rc=0
./.claude/verify.sh > "$RUNNER_TEMP/gate-macos.txt" 2>&1 || rc=$?
cat "$RUNNER_TEMP/gate-macos.txt"
exit "$rc"

# This lane declares, out loud, the only two checks it accepts as skips: the CLI is not
# installed here on purpose (only the ubuntu `verify` job proves check 19 runs, per the
Expand Down Expand Up @@ -368,7 +389,13 @@ jobs:
run: bash ./tests/install-matrix.sh

- name: Run the verification gate
run: bash ./.claude/verify.sh | tee "$RUNNER_TEMP/gate-alpine.txt"
# Not piped, for the reason spelled out on the macOS lane above: no pipefail under this
# shell means `gate | tee` reports tee's status and a red gate passes.
run: |
rc=0
bash ./.claude/verify.sh > "$RUNNER_TEMP/gate-alpine.txt" 2>&1 || rc=$?
cat "$RUNNER_TEMP/gate-alpine.txt"
exit "$rc"

# Same declaration as the macOS lane: the CLI is deliberately absent here too, and the
# version-tag check is expected to skip pre-release. shellcheck is no longer on this list
Expand Down
Loading
Loading