tangier 0.2.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. tangier-0.2.1/.github/actions/build/action.yaml +152 -0
  2. tangier-0.2.1/.github/actions/deploy/action.yaml +93 -0
  3. tangier-0.2.1/.github/actions/tailnet/action.yaml +119 -0
  4. tangier-0.2.1/.github/workflows/build.yaml +87 -0
  5. tangier-0.2.1/.github/workflows/ci.yaml +143 -0
  6. tangier-0.2.1/.github/workflows/release.yaml +35 -0
  7. tangier-0.2.1/.github/workflows/test-empty-matrix.yaml +38 -0
  8. tangier-0.2.1/.gitignore +10 -0
  9. tangier-0.2.1/CONTRIBUTING.md +34 -0
  10. tangier-0.2.1/PKG-INFO +121 -0
  11. tangier-0.2.1/README.md +114 -0
  12. tangier-0.2.1/bin/check-no-dependencies +36 -0
  13. tangier-0.2.1/bin/parity-check +250 -0
  14. tangier-0.2.1/bin/release +81 -0
  15. tangier-0.2.1/bin/test +5 -0
  16. tangier-0.2.1/docs/actions/tailnet.md +134 -0
  17. tangier-0.2.1/docs/guide/1-map-your-app.md +105 -0
  18. tangier-0.2.1/docs/guide/2-selective-tests.md +88 -0
  19. tangier-0.2.1/docs/guide/3-gates.md +135 -0
  20. tangier-0.2.1/docs/guide/4-packages-and-builds.md +102 -0
  21. tangier-0.2.1/docs/guide/5-github-actions.md +201 -0
  22. tangier-0.2.1/docs/guide/6-other-features.md +70 -0
  23. tangier-0.2.1/docs/parity-evidence.txt +487 -0
  24. tangier-0.2.1/docs/specs/changemap.md +191 -0
  25. tangier-0.2.1/docs/specs/gate.md +459 -0
  26. tangier-0.2.1/pipeline.example.toml +140 -0
  27. tangier-0.2.1/pipeline.toml +42 -0
  28. tangier-0.2.1/pyproject.toml +30 -0
  29. tangier-0.2.1/skills/tangier/SKILL.md +21 -0
  30. tangier-0.2.1/skills/tangier/ci.md +64 -0
  31. tangier-0.2.1/skills/tangier/gate.md +76 -0
  32. tangier-0.2.1/skills/tangier/reference/cli.md +189 -0
  33. tangier-0.2.1/skills/tangier/reference/github-actions.md +366 -0
  34. tangier-0.2.1/skills/tangier/reference/pipeline-toml.md +240 -0
  35. tangier-0.2.1/skills/tangier/setup.md +89 -0
  36. tangier-0.2.1/tangier/__init__.py +15 -0
  37. tangier-0.2.1/tangier/__main__.py +5 -0
  38. tangier-0.2.1/tangier/changemap.py +310 -0
  39. tangier-0.2.1/tangier/cli.py +164 -0
  40. tangier-0.2.1/tangier/commands/__init__.py +1 -0
  41. tangier-0.2.1/tangier/commands/args.py +18 -0
  42. tangier-0.2.1/tangier/commands/changemap_cmds.py +243 -0
  43. tangier-0.2.1/tangier/commands/deploy_cmds.py +323 -0
  44. tangier-0.2.1/tangier/commands/gate_cmds.py +822 -0
  45. tangier-0.2.1/tangier/commands/image_cmds.py +155 -0
  46. tangier-0.2.1/tangier/commands/tailnet_cmds.py +106 -0
  47. tangier-0.2.1/tangier/config.py +972 -0
  48. tangier-0.2.1/tangier/deploy.py +194 -0
  49. tangier-0.2.1/tangier/gate.py +745 -0
  50. tangier-0.2.1/tangier/git.py +246 -0
  51. tangier-0.2.1/tangier/github.py +43 -0
  52. tangier-0.2.1/tangier/globs.py +79 -0
  53. tangier-0.2.1/tangier/image.py +167 -0
  54. tangier-0.2.1/tangier/jobs.py +459 -0
  55. tangier-0.2.1/tangier/ranon.py +43 -0
  56. tangier-0.2.1/tangier/runner.py +219 -0
  57. tangier-0.2.1/tangier/tailnet.py +67 -0
  58. tangier-0.2.1/tangier/tests/__init__.py +16 -0
  59. tangier-0.2.1/tangier/tests/fixtures/compose.tmpl.yml +31 -0
  60. tangier-0.2.1/tangier/tests/fixtures/synthetic.toml +100 -0
  61. tangier-0.2.1/tangier/tests/support.py +201 -0
  62. tangier-0.2.1/tangier/tests/test_changemap.py +453 -0
  63. tangier-0.2.1/tangier/tests/test_changemap_cmds.py +461 -0
  64. tangier-0.2.1/tangier/tests/test_config.py +745 -0
  65. tangier-0.2.1/tangier/tests/test_deploy.py +694 -0
  66. tangier-0.2.1/tangier/tests/test_gate.py +1933 -0
  67. tangier-0.2.1/tangier/tests/test_gate_jobs.py +543 -0
  68. tangier-0.2.1/tangier/tests/test_github.py +51 -0
  69. tangier-0.2.1/tangier/tests/test_globs.py +64 -0
  70. tangier-0.2.1/tangier/tests/test_image.py +420 -0
  71. tangier-0.2.1/tangier/tests/test_image_golden.py +112 -0
  72. tangier-0.2.1/tangier/tests/test_tailnet.py +193 -0
  73. tangier-0.2.1/tangier/tests/test_version.py +14 -0
@@ -0,0 +1,152 @@
1
+ name: tangier build
2
+ description: >-
3
+ Build and push one package's content-addressed image, skipping the build
4
+ entirely when that tag is already published.
5
+
6
+ inputs:
7
+ package:
8
+ description: The SHA bucket to build. Must have an `[image.<bucket>]` table in pipeline.toml.
9
+ required: true
10
+ registry-password:
11
+ description: Password for the image registry.
12
+ required: true
13
+ registry-username:
14
+ description: >-
15
+ Username for the image registry. Defaults to `registry`, which is what
16
+ every repo but tangerinecms uses.
17
+ required: false
18
+ default: registry
19
+ extra-secrets:
20
+ description: >-
21
+ Space-separated buildx secret IDS — not values — for secrets not named in
22
+ the package's `[image.<bucket>] secrets`. The value is read from the
23
+ environment variable of the same name upper-cased, so `sentry_auth_token`
24
+ reads SENTRY_AUTH_TOKEN. A secret whose variable is unset is silently
25
+ dropped rather than passed empty, so pass values through the step's `env:`
26
+ block (a composite action cannot read the `secrets` context).
27
+ required: false
28
+ default: ""
29
+ regctl-version:
30
+ description: regctl release to install. Pinned, never `latest`.
31
+ required: false
32
+ default: v0.6.1
33
+ tangier-ref:
34
+ description: >-
35
+ The tangier ref to install the CLI from. Defaults to the ref this action
36
+ was called at. Required when calling the action locally (`uses: ./...`),
37
+ because `github.action_ref` is empty for a local path.
38
+ required: false
39
+ default: ""
40
+
41
+ outputs:
42
+ tag:
43
+ description: The content-hash tag for this package, whether or not a build ran.
44
+ value: ${{ steps.build.outputs.tag }}
45
+ published:
46
+ description: >-
47
+ `true` when this run built and pushed the image, `false` when the tag was
48
+ already published and the build was skipped.
49
+ value: ${{ steps.build.outputs.built }}
50
+
51
+ runs:
52
+ using: composite
53
+ steps:
54
+ - name: Resolve the tangier ref
55
+ id: ref
56
+ shell: bash
57
+ # `github.action_ref` is empty inside a `run:` body but correct when bound
58
+ # through `env:` (actions/runner#2473), so every read here goes via the
59
+ # environment. It is also empty for a local `uses: ./...`, which is how
60
+ # tangier tests its own actions — hence the input, and a hard error rather
61
+ # than a silent fallback to `main`.
62
+ env:
63
+ ACTION_REF: ${{ github.action_ref }}
64
+ INPUT_REF: ${{ inputs.tangier-ref }}
65
+ run: |
66
+ set -euo pipefail
67
+ REF="${INPUT_REF:-$ACTION_REF}"
68
+ if [ -z "$REF" ]; then
69
+ echo "::error::cannot determine which tangier ref to install. Pass the tangier-ref input when calling this action by local path." >&2
70
+ exit 1
71
+ fi
72
+ echo "ref=$REF" >> "$GITHUB_OUTPUT"
73
+
74
+ - uses: astral-sh/setup-uv@v5
75
+ with:
76
+ version: "0.5.29"
77
+
78
+ - name: Install tangier
79
+ shell: bash
80
+ # `uv tool install`, not `uvx --from`, so `tangier` stays on PATH for any
81
+ # repo script that runs after this action.
82
+ env:
83
+ TANGIER_REF: ${{ steps.ref.outputs.ref }}
84
+ run: |
85
+ set -euo pipefail
86
+ uv tool install "git+https://github.com/sminnee/tangier@${TANGIER_REF}"
87
+
88
+ - name: Install regctl
89
+ shell: bash
90
+ # Pinned by version, never `releases/latest`: a moving binary in the hot
91
+ # path of every build is a silent behaviour change with no PR anywhere.
92
+ # `set -euo pipefail` matters here — without it a failed curl leaves a
93
+ # zero-byte file that fails confusingly several steps later.
94
+ env:
95
+ REGCTL_VERSION: ${{ inputs.regctl-version }}
96
+ run: |
97
+ set -euo pipefail
98
+ # Into $RUNNER_TEMP rather than /usr/local/bin: that directory happens to
99
+ # be writable on GitHub-hosted images, but not on a self-hosted runner or
100
+ # a container job running as non-root — and `runs-on` is already an input.
101
+ mkdir -p "$RUNNER_TEMP/bin"
102
+ curl -fsSL -o "$RUNNER_TEMP/bin/regctl" \
103
+ "https://github.com/regclient/regclient/releases/download/${REGCTL_VERSION}/regctl-linux-amd64"
104
+ chmod 755 "$RUNNER_TEMP/bin/regctl"
105
+ echo "$RUNNER_TEMP/bin" >> "$GITHUB_PATH"
106
+ "$RUNNER_TEMP/bin/regctl" version
107
+
108
+ - uses: docker/setup-buildx-action@v3
109
+
110
+ - name: Registry login
111
+ shell: bash
112
+ # `[registry] url` is host + namespace, but `docker login` takes only the
113
+ # host: `${URL%%/*}` gives `registry.sminn.ee` from
114
+ # `registry.sminn.ee/tangerine`, and keeps a `:port` if one is configured.
115
+ # Read with tomllib rather than the CLI so this step does not depend on
116
+ # the install above having succeeded.
117
+ env:
118
+ REGISTRY_USERNAME: ${{ inputs.registry-username }}
119
+ REGISTRY_PASSWORD: ${{ inputs.registry-password }}
120
+ run: |
121
+ set -euo pipefail
122
+ URL="$(python3 -c '
123
+ import tomllib
124
+ with open("pipeline.toml", "rb") as fh:
125
+ print(tomllib.load(fh)["registry"]["url"])
126
+ ')"
127
+ HOST="${URL%%/*}"
128
+ echo "Logging in to $HOST"
129
+ printf '%s' "$REGISTRY_PASSWORD" | docker login "$HOST" \
130
+ --username "$REGISTRY_USERNAME" --password-stdin
131
+
132
+ - name: Build and push
133
+ id: build
134
+ shell: bash
135
+ # tangier probes the registry first and skips the build when the
136
+ # content-hash tag is already published — the idempotence that
137
+ # content-addressed tagging exists for. It emits `tag` and `built` to
138
+ # $GITHUB_OUTPUT itself, which is where this action's outputs come from.
139
+ #
140
+ # `resolve_secrets` reads os.environ and drops any secret whose variable
141
+ # is unset, so a caller supplies values through this step's `env:` and
142
+ # needs no input for the ids already in `[image.<bucket>] secrets`.
143
+ env:
144
+ PACKAGE: ${{ inputs.package }}
145
+ EXTRA_SECRETS: ${{ inputs.extra-secrets }}
146
+ run: |
147
+ set -euo pipefail
148
+ ARGS=()
149
+ for id in $EXTRA_SECRETS; do
150
+ ARGS+=(--secret "id=$id")
151
+ done
152
+ tangier image build "$PACKAGE" --push "${ARGS[@]+"${ARGS[@]}"}"
@@ -0,0 +1,93 @@
1
+ name: tangier deploy
2
+ description: >-
3
+ Connect to the tailnet, then render and apply this environment's manifests
4
+ with the current content-addressed image tags.
5
+
6
+ # READ docs/actions/tailnet.md BEFORE changing a call site. The separation
7
+ # between uat and prod is enforced by the `environment:` line on the calling
8
+ # job, and it is no longer visible from here.
9
+
10
+ inputs:
11
+ env:
12
+ description: Environment name. Must have a `[deploy.<env>]` table in pipeline.toml.
13
+ required: true
14
+ client-id:
15
+ description: >-
16
+ OAuth client ID for this environment's deploy identity. Pass
17
+ `${{ vars.TS_DEPLOY_CLIENT_ID }}` — a composite action cannot read the
18
+ `vars` context (actions/runner#2551).
19
+ required: true
20
+ audience:
21
+ description: OIDC audience for this environment. Pass `${{ vars.TS_DEPLOY_AUDIENCE }}`.
22
+ required: true
23
+ tailscale-version:
24
+ description: Tailscale release to install. Pinned, never `latest`.
25
+ required: false
26
+ default: "1.78.1"
27
+ tangier-ref:
28
+ description: >-
29
+ The tangier ref to install the CLI from. Defaults to the ref this action
30
+ was called at. Required when calling the action locally (`uses: ./...`),
31
+ because `github.action_ref` is empty for a local path.
32
+ required: false
33
+ default: ""
34
+
35
+ runs:
36
+ using: composite
37
+ steps:
38
+ - name: Resolve the tangier ref
39
+ id: ref
40
+ shell: bash
41
+ # Resolved at THIS action's top level and passed down, rather than left to
42
+ # the nested action: `github.action_ref` is only correct for the action it
43
+ # is evaluated in, and binding it through `env:` is required because it is
44
+ # empty inside a `run:` body (actions/runner#2473).
45
+ env:
46
+ ACTION_REF: ${{ github.action_ref }}
47
+ INPUT_REF: ${{ inputs.tangier-ref }}
48
+ run: |
49
+ set -euo pipefail
50
+ REF="${INPUT_REF:-$ACTION_REF}"
51
+ if [ -z "$REF" ]; then
52
+ echo "::error::cannot determine which tangier ref to install. Pass the tangier-ref input when calling this action by local path." >&2
53
+ exit 1
54
+ fi
55
+ echo "ref=$REF" >> "$GITHUB_OUTPUT"
56
+
57
+ - uses: sminnee/tangier/.github/actions/tailnet@v0
58
+ with:
59
+ client-id: ${{ inputs.client-id }}
60
+ audience: ${{ inputs.audience }}
61
+ env: ${{ inputs.env }}
62
+ version: ${{ inputs.tailscale-version }}
63
+
64
+ - uses: astral-sh/setup-uv@v5
65
+ with:
66
+ version: "0.5.29"
67
+
68
+ - name: Install tangier
69
+ shell: bash
70
+ # `uv tool install`, not `uvx --from`: `tangier` must stay on PATH for any
71
+ # repo script that runs after this action.
72
+ env:
73
+ TANGIER_REF: ${{ steps.ref.outputs.ref }}
74
+ run: |
75
+ set -euo pipefail
76
+ uv tool install "git+https://github.com/sminnee/tangier@${TANGIER_REF}"
77
+
78
+ - name: Deploy
79
+ shell: bash
80
+ # `--summary` writes the build table to $GITHUB_STEP_SUMMARY before
81
+ # anything is applied — pass 1 overwrites the deployed tags it reads, and
82
+ # the table is most wanted when the deploy then fails.
83
+ #
84
+ # `set -euo pipefail` is a behaviour change from the bash this replaces,
85
+ # which had no `set -e` and fell through a failing summary into the real
86
+ # deploy. If it proves annoying, the relief valve is
87
+ # `continue-on-error: true` on a separate summary step — not dropping
88
+ # `set -e`.
89
+ env:
90
+ DEPLOY_ENV: ${{ inputs.env }}
91
+ run: |
92
+ set -euo pipefail
93
+ tangier deploy "$DEPLOY_ENV" --summary
@@ -0,0 +1,119 @@
1
+ name: tangier tailnet
2
+ description: >-
3
+ Connect the runner to the tailnet as this environment's deploy identity and
4
+ point kubectl at the Tailscale Kubernetes operator.
5
+
6
+ # READ docs/actions/tailnet.md BEFORE changing a call site. The separation
7
+ # between uat and prod is enforced by the `environment:` line on the calling
8
+ # job, and it is no longer visible from here.
9
+
10
+ inputs:
11
+ client-id:
12
+ description: >-
13
+ OAuth client ID for this environment's deploy identity. Pass
14
+ `${{ vars.TS_DEPLOY_CLIENT_ID }}` — a composite action cannot read the
15
+ `vars` context at all (actions/runner#2551), so it must be forwarded.
16
+ The value differs per environment; the NAME is deliberately the same.
17
+ required: true
18
+ audience:
19
+ description: >-
20
+ OIDC audience for this environment. Pass `${{ vars.TS_DEPLOY_AUDIENCE }}`.
21
+ Same reasoning, and the same deliberate name-sharing, as `client-id`.
22
+ required: true
23
+ env:
24
+ description: >-
25
+ Environment name, used to look up `[tailnet.<env>] tag` in pipeline.toml.
26
+ Omit when passing `tag` directly.
27
+ required: false
28
+ default: ""
29
+ tag:
30
+ description: >-
31
+ Tailnet ACL tag, overriding config. For a repo with no pipeline.toml —
32
+ k8s-cluster is helm and kubectl with no images, and a config describing
33
+ no tags, buckets or images would misrepresent it and trip
34
+ `_warn_tags_without_input`.
35
+ required: false
36
+ default: ""
37
+ operator:
38
+ description: >-
39
+ Tailscale Kubernetes operator hostname, overriding `[tailnet] operator`.
40
+ Defaults to `tailscale-operator` when there is no config to read.
41
+ required: false
42
+ default: ""
43
+ version:
44
+ description: Tailscale release to install. Pinned, never `latest`.
45
+ required: false
46
+ default: "1.78.1"
47
+
48
+ outputs:
49
+ tag:
50
+ description: The tailnet tag this runner authenticated as.
51
+ value: ${{ steps.resolve.outputs.tag }}
52
+
53
+ runs:
54
+ using: composite
55
+ steps:
56
+ - name: Resolve the tag and operator
57
+ id: resolve
58
+ shell: bash
59
+ # Read with inline tomllib rather than through the CLI: four of the eight
60
+ # call sites want the tailnet connect ALONE, and making them install uv
61
+ # and tangier to read one string would defeat the point of the action.
62
+ env:
63
+ INPUT_TAG: ${{ inputs.tag }}
64
+ INPUT_ENV: ${{ inputs.env }}
65
+ INPUT_OPERATOR: ${{ inputs.operator }}
66
+ run: |
67
+ set -euo pipefail
68
+ # Precedence: explicit input, then config. An explicit `tag` also means
69
+ # `pipeline.toml` need not exist at all — which is how a repo with no
70
+ # images (k8s-cluster is helm and kubectl) uses this action without a
71
+ # config file that would misrepresent it.
72
+ TAG="$INPUT_TAG"
73
+ OPERATOR="$INPUT_OPERATOR"
74
+
75
+ if [ -f pipeline.toml ]; then
76
+ # Two lines out, so an absent value stays distinguishable from an
77
+ # empty one without quoting games.
78
+ CFG="$(INPUT_ENV="$INPUT_ENV" python3 -c '
79
+ import os, tomllib
80
+ with open("pipeline.toml", "rb") as fh:
81
+ cfg = tomllib.load(fh).get("tailnet", {})
82
+ env = os.environ.get("INPUT_ENV", "")
83
+ print((cfg.get(env) or {}).get("tag", "") if env else "")
84
+ print(cfg.get("operator", "tailscale-operator"))
85
+ ')"
86
+ CFG_TAG="$(printf '%s' "$CFG" | sed -n 1p)"
87
+ CFG_OPERATOR="$(printf '%s' "$CFG" | sed -n 2p)"
88
+ TAG="${TAG:-$CFG_TAG}"
89
+ OPERATOR="${OPERATOR:-$CFG_OPERATOR}"
90
+ fi
91
+ OPERATOR="${OPERATOR:-tailscale-operator}"
92
+
93
+ if [ -z "$TAG" ]; then
94
+ echo "::error::no tailnet tag for env [${INPUT_ENV:-<unset>}]. Add a [tailnet.<env>] table to pipeline.toml, or pass the tag input." >&2
95
+ exit 1
96
+ fi
97
+ echo "tag=$TAG" >> "$GITHUB_OUTPUT"
98
+ echo "operator=$OPERATOR" >> "$GITHUB_OUTPUT"
99
+ echo "Connecting as $TAG via $OPERATOR"
100
+
101
+ - name: Connect to the tailnet
102
+ uses: tailscale/github-action@v3
103
+ with:
104
+ oauth-client-id: ${{ inputs.client-id }}
105
+ tags: ${{ steps.resolve.outputs.tag }}
106
+ version: ${{ inputs.version }}
107
+ use-cache: "true"
108
+ args: --advertise-tags=${{ steps.resolve.outputs.tag }}
109
+ env:
110
+ TS_EXPERIMENT_OAUTH_AUDIENCE: ${{ inputs.audience }}
111
+
112
+ - name: Point kubectl at the operator
113
+ shell: bash
114
+ env:
115
+ OPERATOR: ${{ steps.resolve.outputs.operator }}
116
+ run: |
117
+ set -euo pipefail
118
+ tailscale configure kubeconfig "$OPERATOR"
119
+ kubectl config current-context
@@ -0,0 +1,87 @@
1
+ name: Build packages
2
+
3
+ # One matrixed job in place of a hand-written job per package. Feed `packages`
4
+ # from `tangier changemap build-matrix`, which emits exactly this shape.
5
+
6
+ on:
7
+ workflow_call:
8
+ inputs:
9
+ packages:
10
+ description: >-
11
+ JSON array of SHA buckets to build, e.g. '["astrochat","smartypants"]'.
12
+ A JSON *string* rather than a list because matrices need `fromJSON` and
13
+ Actions has no typed list input. `changemap build-matrix` emits it.
14
+ required: true
15
+ type: string
16
+ registry-username:
17
+ description: Registry username. Defaults to `registry`.
18
+ required: false
19
+ type: string
20
+ default: registry
21
+ extra-secrets:
22
+ description: Space-separated extra buildx secret IDs (not values).
23
+ required: false
24
+ type: string
25
+ default: ""
26
+ regctl-version:
27
+ required: false
28
+ type: string
29
+ default: v0.6.1
30
+ tangier-ref:
31
+ description: tangier ref to install the CLI from. Defaults to this workflow's ref.
32
+ required: false
33
+ type: string
34
+ default: ""
35
+ fail-fast:
36
+ description: >-
37
+ Cancel sibling legs when one fails. Defaults to false: an unrelated
38
+ package's failure should not discard a build that already succeeded,
39
+ and content-addressed tags make a partial set safe to resume.
40
+ required: false
41
+ type: boolean
42
+ default: false
43
+ runs-on:
44
+ required: false
45
+ type: string
46
+ default: ubuntu-latest
47
+ secrets:
48
+ registry-password:
49
+ required: true
50
+ sentry-auth-token:
51
+ description: >-
52
+ Passed to EVERY matrix leg — Actions offers no per-leg secret routing.
53
+ Safe because tangier fails closed: `resolve_secrets` emits `--secret`
54
+ only for ids the bucket's `[image.<bucket>] secrets` declares, so a
55
+ package that does not want it never references it.
56
+ required: false
57
+
58
+ jobs:
59
+ build:
60
+ # An empty `strategy.matrix` is a hard error ("Matrix vector 'package' does
61
+ # not contain any values"), not a skip — and any docs-only PR produces one.
62
+ # The quoting is load-bearing: `!= []` does not evaluate false for an empty
63
+ # array, so the comparison is against the JSON *string*.
64
+ if: ${{ inputs.packages != '[]' && inputs.packages != '' }}
65
+ runs-on: ${{ inputs.runs-on }}
66
+ strategy:
67
+ fail-fast: ${{ inputs.fail-fast }}
68
+ matrix:
69
+ package: ${{ fromJSON(inputs.packages) }}
70
+ name: build ${{ matrix.package }}
71
+ steps:
72
+ - uses: actions/checkout@v4
73
+ with:
74
+ # tangier hashes the git tree to derive the tag, so a shallow checkout
75
+ # would produce a different hash from a local run.
76
+ fetch-depth: 0
77
+
78
+ - uses: sminnee/tangier/.github/actions/build@v0
79
+ with:
80
+ package: ${{ matrix.package }}
81
+ registry-username: ${{ inputs.registry-username }}
82
+ registry-password: ${{ secrets.registry-password }}
83
+ extra-secrets: ${{ inputs.extra-secrets }}
84
+ regctl-version: ${{ inputs.regctl-version }}
85
+ tangier-ref: ${{ inputs.tangier-ref }}
86
+ env:
87
+ SENTRY_AUTH_TOKEN: ${{ secrets.sentry-auth-token }}
@@ -0,0 +1,143 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ # The Python versions are members of the `[gate.test]` group in pipeline.toml.
11
+ # One step for each member, so each version reports and reuses its own record.
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ # Depth 20 follows the checkout-depth rule in skills/tangier/reference/
15
+ # github-actions.md. These gates have no placeholder, so they need only HEAD^1.
16
+ - uses: actions/checkout@v4
17
+ with:
18
+ fetch-depth: 20
19
+ - uses: astral-sh/setup-uv@v6
20
+ with:
21
+ # A verified gate never calls uv, and an empty cache fails the post step.
22
+ enable-cache: false
23
+ - name: Run test.py311
24
+ # No setup-python: the runner's own Python runs tangier, which is the
25
+ # environment tangier must work in.
26
+ env:
27
+ EVENT: ${{ github.event_name }}
28
+ run: |
29
+ # A pull request reuses a gate record. Any other event is the full
30
+ # build that does not consult records.
31
+ flags=(--read-only)
32
+ if [ "$EVENT" = pull_request ]; then flags+=(--base HEAD^1); else flags+=(--full); fi
33
+ python3 -m tangier gate run test.py311 "${flags[@]}"
34
+ - name: Run test.py312
35
+ env:
36
+ EVENT: ${{ github.event_name }}
37
+ run: |
38
+ flags=(--read-only)
39
+ if [ "$EVENT" = pull_request ]; then flags+=(--base HEAD^1); else flags+=(--full); fi
40
+ python3 -m tangier gate run test.py312 "${flags[@]}"
41
+ - name: Run test.py313
42
+ env:
43
+ EVENT: ${{ github.event_name }}
44
+ run: |
45
+ flags=(--read-only)
46
+ if [ "$EVENT" = pull_request ]; then flags+=(--base HEAD^1); else flags+=(--full); fi
47
+ python3 -m tangier gate run test.py313 "${flags[@]}"
48
+ - name: CLI smoke check
49
+ run: python3 -m tangier --help
50
+
51
+ no-dependencies:
52
+ # The check is `[gate.no-dependencies]` in pipeline.toml.
53
+ runs-on: ubuntu-latest
54
+ steps:
55
+ - uses: actions/checkout@v4
56
+ with:
57
+ fetch-depth: 20
58
+ - name: Run no-dependencies
59
+ # No setup-python: the runner's own Python is the one tangier must work on.
60
+ env:
61
+ EVENT: ${{ github.event_name }}
62
+ run: |
63
+ flags=(--read-only)
64
+ if [ "$EVENT" = pull_request ]; then flags+=(--base HEAD^1); else flags+=(--full); fi
65
+ python3 -m tangier gate run no-dependencies "${flags[@]}"
66
+
67
+ lint:
68
+ runs-on: ubuntu-latest
69
+ steps:
70
+ - uses: actions/checkout@v4
71
+ with:
72
+ fetch-depth: 20
73
+ - uses: astral-sh/setup-uv@v6
74
+ with:
75
+ enable-cache: false
76
+ - run: uv tool install ruff
77
+ - name: Run lint.check
78
+ env:
79
+ EVENT: ${{ github.event_name }}
80
+ run: |
81
+ flags=(--read-only)
82
+ if [ "$EVENT" = pull_request ]; then flags+=(--base HEAD^1); else flags+=(--full); fi
83
+ python3 -m tangier gate run lint.check "${flags[@]}"
84
+ - name: Run lint.format
85
+ env:
86
+ EVENT: ${{ github.event_name }}
87
+ run: |
88
+ flags=(--read-only)
89
+ if [ "$EVENT" = pull_request ]; then flags+=(--base HEAD^1); else flags+=(--full); fi
90
+ python3 -m tangier gate run lint.format "${flags[@]}"
91
+
92
+ lint-actions:
93
+ # `action-validator` covers `actions/*/action.yaml`, the file type nothing
94
+ # in this estate lints today. `actionlint` shellchecks every `run:` body,
95
+ # which is the higher-value of the two given these actions are mostly shell.
96
+ runs-on: ubuntu-latest
97
+ steps:
98
+ - uses: actions/checkout@v4
99
+ - name: action-validator
100
+ # One file per invocation — the CLI takes a single path.
101
+ run: |
102
+ set -euo pipefail
103
+ for f in .github/workflows/*.yaml .github/actions/*/action.yaml; do
104
+ echo "validating $f"
105
+ npx --yes @action-validator/cli@0.6.0 "$f"
106
+ done
107
+ - name: actionlint
108
+ # Covers workflows only — composite `action.yaml` files are the next step.
109
+ uses: docker://rhysd/actionlint:1.7.7
110
+ with:
111
+ args: -color
112
+ - name: shellcheck composite action run bodies
113
+ # actionlint shellchecks `run:` bodies in WORKFLOWS but does not read
114
+ # `actions/*/action.yaml`, so these scripts are otherwise unlinted — and
115
+ # they are mostly shell. Extract each body and check it directly.
116
+ run: |
117
+ set -euo pipefail
118
+ # Cleared, not just created: stale extracts from an earlier run would
119
+ # otherwise be checked against code no longer in the tree.
120
+ rm -rf "$RUNNER_TEMP/sc"
121
+ mkdir -p "$RUNNER_TEMP/sc"
122
+ python3 - <<'PY'
123
+ import os, re
124
+ out = os.path.join(os.environ["RUNNER_TEMP"], "sc")
125
+ for name in sorted(os.listdir(".github/actions")):
126
+ path = f".github/actions/{name}/action.yaml"
127
+ if not os.path.exists(path):
128
+ continue
129
+ text = open(path).read()
130
+ for i, block in enumerate(re.findall(r" run: \|\n((?: .*\n|\n)+)", text)):
131
+ body = "\n".join(line[8:] for line in block.splitlines())
132
+ with open(os.path.join(out, f"{name}_{i}.sh"), "w") as fh:
133
+ fh.write("#!/bin/bash\n" + body + "\n")
134
+ PY
135
+ count=$(find "$RUNNER_TEMP/sc" -name '*.sh' | wc -l)
136
+ test "$count" -gt 0 || { echo "::error::extracted no run bodies — the regex has drifted"; exit 1; }
137
+ echo "shellchecking $count run bodies, plus bin/"
138
+ find "$RUNNER_TEMP/sc" -name '*.sh' -print0 | xargs -0 shellcheck
139
+ # bin/ is otherwise ungated, and it holds the release script that
140
+ # force-moves the alias every consumer pins. `parity-check` is not in
141
+ # the list: it has a pre-existing SC2015 that predates this gate, and
142
+ # cleaning it belongs in its own change rather than here.
143
+ shellcheck bin/release bin/test
@@ -0,0 +1,35 @@
1
+ name: Release
2
+
3
+ # Publish to PyPI when an exact version tag is pushed. The `v0`
4
+ # alias does not match `v*.*.*`, so moving it publishes nothing.
5
+ #
6
+ # Trusted publishing, so the repo holds no PyPI token. CONTRIBUTING.md has the setup.
7
+
8
+ on:
9
+ push:
10
+ tags: ["v*.*.*"]
11
+
12
+ jobs:
13
+ publish:
14
+ runs-on: ubuntu-latest
15
+ environment: pypi
16
+ permissions:
17
+ contents: read
18
+ id-token: write
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: astral-sh/setup-uv@v6
22
+ with:
23
+ enable-cache: false
24
+ - name: Check the tag matches the package version
25
+ env:
26
+ TAG: ${{ github.ref_name }}
27
+ run: |
28
+ version="$(python3 -c 'import tomllib; print(tomllib.load(open("pyproject.toml", "rb"))["project"]["version"])')"
29
+ if [ "$TAG" != "v$version" ]; then
30
+ echo "::error::tag $TAG does not match pyproject.toml version $version"
31
+ exit 1
32
+ fi
33
+ - run: uv build
34
+ # Pinned by commit: this job can publish as tangier, so a moved tag must not change what runs.
35
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
@@ -0,0 +1,38 @@
1
+ name: Empty matrix guard
2
+
3
+ # Two lines guarding the footgun most likely to break a consumer's PR flow: a
4
+ # `strategy.matrix` over `[]` is a hard error in Actions, not a skip, and any
5
+ # docs-only PR produces exactly that. The assertion is that the run SUCCEEDS
6
+ # with the build job skipped.
7
+
8
+ on:
9
+ push:
10
+ branches: [main]
11
+ pull_request:
12
+
13
+ jobs:
14
+ empty:
15
+ uses: ./.github/workflows/build.yaml
16
+ with:
17
+ packages: "[]"
18
+ secrets:
19
+ registry-password: unused
20
+
21
+ assert-skipped:
22
+ needs: empty
23
+ if: ${{ always() }}
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - name: Assert the build job was skipped, not failed
27
+ env:
28
+ RESULT: ${{ needs.empty.result }}
29
+ run: |
30
+ set -euo pipefail
31
+ # Exactly `skipped`. Accepting `success` too would let a regression
32
+ # that removed the `if:` guard pass whenever the matrix happened to
33
+ # run — which is the one thing this test exists to catch.
34
+ test "$RESULT" = skipped || {
35
+ echo "::error::expected the build job to be skipped, got result=$RESULT"
36
+ exit 1
37
+ }
38
+ echo "empty matrix handled: build job skipped"
@@ -0,0 +1,10 @@
1
+ .claude/CLAUDE.local.md
2
+ __pycache__/
3
+ *.pyc
4
+ .ruff_cache/
5
+ dist/
6
+ *.egg-info/
7
+ .env
8
+ .drafts/
9
+ .claude/
10
+ uv.lock