feat: Gitea Actions compatibility test suite
Gitea Actions Compatibility Suite / aggregate (push) Failing after 0s
Gitea Actions Compatibility Suite / c8 (push) Failing after 1s
Gitea Actions Compatibility Suite / c9 (push) Failing after 0s
Gitea Actions Compatibility Suite / c10 (push) Failing after 0s
Gitea Actions Compatibility Suite / probe (push) Successful in 0s
Gitea Actions Compatibility Suite / c1 (push) Successful in 8s
Gitea Actions Compatibility Suite / c2 (push) Successful in 5s
Gitea Actions Compatibility Suite / c3 (push) Successful in 1m24s
Gitea Actions Compatibility Suite / c4 (push) Failing after 9s
Gitea Actions Compatibility Suite / c5 (push) Successful in 3s
Gitea Actions Compatibility Suite / c6 (push) Successful in 1s
Gitea Actions Compatibility Suite / c7 (push) Failing after 32s

A self-validating test suite that proves Gitea's implementations of
GitHub-style actions behave like GitHub's. Push to any Gitea instance
with a registered runner — on push or manual dispatch, it runs 10
independent test workflows covering checkout, artifacts, caching,
language setup, expression contexts, workflow commands, services,
composite/reusable workflows, control flow, and known divergences.

Components:
- lib/assert.sh — self-asserting shell helper library (assert_eq,
  assert_contains, assert_exists, assert_not_exists, assert_match,
  sha256_of) with verified negative fail path
- 10 reusable workflows (on: workflow_call):
  01-checkout, 02-artifacts, 03-cache, 04-setup-runtime,
  05-contexts, 06-workflow-commands, 07-services,
  08-composite-reusable, 09-control-flow, 10-gitea-divergences
- 00-suite-runner.yml — sole entry (on: push + workflow_dispatch):
  probe gate (Gitea >= 1.22.0) -> c1-c10 parallel callers
  (c5/c8 forward secrets: inherit for act_runner #125) ->
  aggregate (if: always, skipped-as-failure)
- Fixtures: LFS 5MB binary, git tag v1-test, 4 language lockfiles
  (npm/go/pip/maven), sparse-cone layout
- Makefile: make lint (actionlint static gate)
- README: prerequisites, per-test expectations, correction table,
  manual eyeball checklist for UI-only behaviors
This commit is contained in:
tqcq
2026-07-07 12:12:17 +00:00
commit 471138f0c4
29 changed files with 1750 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
*.bin filter=lfs diff=lfs merge=lfs -text
+23
View File
@@ -0,0 +1,23 @@
# .github/actionlint.yaml - narrowly-scoped lint config for the Gitea compat suite
#
# This suite targets Gitea Actions on a self-hosted runner, NOT github.com.
# actionlint's bundled metadata marks several v3 actions (e.g. actions/cache@v3)
# as "too old to run on GitHub Actions" because their original releases shipped
# on the now-deprecated node16 runtime. On a self-hosted/Gitea runner this check
# is a false positive: Gitea resolves actions by mirroring and the actual
# resolved v3 release uses a current runtime.
#
# The actionlint config documentation explicitly provides this ignore pattern
# for "(outdated) self-hosted runner environment" use cases. We apply it
# narrowly: ONLY the old-runner check is suppressed across the workflow
# directory. Every other actionlint check (syntax, expression typing, shellcheck
# integration, ...) remains fully enforced.
#
# NOTE: actionlint auto-discovers config at .github/actionlint.yaml. The README's
# repo-layout note mentions .github/workflows/actionlint.yaml, but actionlint
# does NOT load config from the workflows/ subdirectory, so the real path is
# .github/actionlint.yaml.
paths:
.github/workflows/**/*.{yml,yaml}:
ignore:
- 'the runner of ".+" action is too old to run on GitHub Actions'
+14
View File
@@ -0,0 +1,14 @@
{
"problemMatcher": [
{
"owner": "gitea-divergences-test",
"pattern": [
{
"regexp": "^(error|warning|notice): (.*)$",
"severity": 1,
"message": 2
}
]
}
]
}
+156
View File
@@ -0,0 +1,156 @@
---
# 00-suite-runner.yml - entry point for the Gitea Actions compatibility suite.
#
# This is the SOLE entry workflow. It has the push/workflow_dispatch triggers.
# The ten sub-workflows (01-10) are `on: workflow_call` only and are invoked by
# the caller jobs c1..c10 below. Numeric filename prefixes are for HUMAN reading
# order only; execution order comes exclusively from `needs:` dependencies.
#
# Flow: probe (hard gate) -> c1..c10 (parallel callers) -> aggregate (must-all-pass).
# If probe fails, every cN is skipped (it needs probe), and aggregate (if: always())
# sees them as non-success and turns the run red - never green.
name: Gitea Actions Compatibility Suite
on: [push, workflow_dispatch]
permissions:
contents: read
jobs:
probe:
runs-on: ubuntu-24.04
outputs:
gitea-version: ${{ steps.check.outputs.gitea-version }}
steps:
- name: check-gitea-version
id: check
run: |
# Probe the Gitea instance version. GITHUB_API_URL is the GitHub-Actions
# compatible endpoint Gitea exposes; GITEA_API_URL is the native one.
VERSION=""
if [ -n "${GITHUB_API_URL:-}" ]; then
VERSION=$(curl -sf "${GITHUB_API_URL}/version" 2>/dev/null | grep -o '"version":"[^"]*"' | head -1 | cut -d'"' -f4) || true
fi
if [ -n "${GITEA_API_URL:-}" ]; then
VERSION=$(curl -sf "${GITEA_API_URL}/version" 2>/dev/null | grep -o '"version":"[^"]*"' | head -1 | cut -d'"' -f4) || true
fi
echo "Detected Gitea version: ${VERSION:-unknown}"
echo "gitea-version=${VERSION:-unknown}" >> "$GITHUB_OUTPUT"
# Assert >= 1.22.0 (minimum to parse/accept workflows).
MAJOR=0; MINOR=0; PATCH=0
if [[ "$VERSION" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then
MAJOR="${BASH_REMATCH[1]}"
MINOR="${BASH_REMATCH[2]}"
PATCH="${BASH_REMATCH[3]}"
fi
if [ "$MAJOR" -lt 1 ] || { [ "$MAJOR" -eq 1 ] && [ "$MINOR" -lt 22 ]; }; then
echo "::error::Gitea version ${VERSION:-unknown} is below 1.22.0 (minimum to parse/accept workflows). Suite aborted."
exit 1
fi
echo "Gitea version ${VERSION} (major=${MAJOR} minor=${MINOR} patch=${PATCH}) >= 1.22.0: OK"
- name: check-action-mirroring
continue-on-error: true
run: |
# Best-effort: check that action mirroring can resolve at least one actions/* ref.
echo "Best-effort action mirroring check (informational only, not a hard gate)"
echo "If actions fail to resolve at runtime, check ACTIONS_URL / DEFAULT_ACTIONS_URL settings"
- name: check-storage-cache
continue-on-error: true
run: |
# Best-effort: note about storage and cache.
echo "Storage backend and cache server should be configured. Artifacts and cache tests will fail if not."
# --- Caller jobs: each invokes a reusable workflow via `uses:`.
# Execution order is governed ONLY by `needs:` here, NOT by the numeric
# filename prefixes of the referenced workflows. The cN jobs are independent
# of each other (they all only need probe) and run in parallel.
c1:
needs: probe
uses: ./.github/workflows/01-checkout.yml
c2:
needs: probe
uses: ./.github/workflows/02-artifacts.yml
c3:
needs: probe
uses: ./.github/workflows/03-cache.yml
c4:
needs: probe
uses: ./.github/workflows/04-setup-runtime.yml
c5:
needs: probe
uses: ./.github/workflows/05-contexts.yml
secrets: inherit
c6:
needs: probe
uses: ./.github/workflows/06-workflow-commands.yml
c7:
needs: probe
uses: ./.github/workflows/07-services.yml
c8:
needs: probe
uses: ./.github/workflows/08-composite-reusable.yml
with:
who: gitea-compat-suite
secrets: inherit
c9:
needs: probe
uses: ./.github/workflows/09-control-flow.yml
c10:
needs: probe
uses: ./.github/workflows/10-gitea-divergences.yml
aggregate:
needs: [probe, c1, c2, c3, c4, c5, c6, c7, c8, c9, c10]
if: always()
runs-on: ubuntu-24.04
steps:
- name: aggregate-results
run: |
# Fail unless probe AND every cN is 'success'.
# A skipped (probe failed) or cancelled component counts as failure,
# which is why `if: always()` runs this job even when upstream failed.
#
# NOTE: GitHub Actions expressions are evaluated by the runner BEFORE
# shell runs, so the needs context cannot be indexed by a shell
# variable (writing needs.$j.result would never substitute $j into the
# pre-evaluated expression). Each needs.<job>.result is written out
# explicitly below.
echo "probe=${{ needs.probe.result }}"
echo "c1=${{ needs.c1.result }}"
echo "c2=${{ needs.c2.result }}"
echo "c3=${{ needs.c3.result }}"
echo "c4=${{ needs.c4.result }}"
echo "c5=${{ needs.c5.result }}"
echo "c6=${{ needs.c6.result }}"
echo "c7=${{ needs.c7.result }}"
echo "c8=${{ needs.c8.result }}"
echo "c9=${{ needs.c9.result }}"
echo "c10=${{ needs.c10.result }}"
all_ok=true
if [ "${{ needs.probe.result }}" != "success" ]; then all_ok=false; fi
if [ "${{ needs.c1.result }}" != "success" ]; then all_ok=false; fi
if [ "${{ needs.c2.result }}" != "success" ]; then all_ok=false; fi
if [ "${{ needs.c3.result }}" != "success" ]; then all_ok=false; fi
if [ "${{ needs.c4.result }}" != "success" ]; then all_ok=false; fi
if [ "${{ needs.c5.result }}" != "success" ]; then all_ok=false; fi
if [ "${{ needs.c6.result }}" != "success" ]; then all_ok=false; fi
if [ "${{ needs.c7.result }}" != "success" ]; then all_ok=false; fi
if [ "${{ needs.c8.result }}" != "success" ]; then all_ok=false; fi
if [ "${{ needs.c9.result }}" != "success" ]; then all_ok=false; fi
if [ "${{ needs.c10.result }}" != "success" ]; then all_ok=false; fi
if [ "$all_ok" != "true" ]; then
echo "::error::Suite FAILED: not all components reported success"
exit 1
fi
echo "Suite PASSED: all components reported success"
+167
View File
@@ -0,0 +1,167 @@
---
name: Checkout Variants (C1)
# Reusable workflow invoked by caller job c1 in 00-suite-runner.yml.
# Probes actions/checkout@v4 across its input matrix: default, fetch-depth,
# lfs, ref (tag), path, sparse-checkout cone mode, persist-credentials
# (true/false, with a non-vacuous assertion that the embedded credential
# token differs between the two), and an optional submodule checkout gated
# on a workflow_call input. on: workflow_call ONLY (no push/pull_request).
on:
workflow_call:
inputs:
usesubmodule:
description: Set to "true" to enable the submodule checkout test.
required: false
type: string
default: 'false'
jobs:
checkout-variants:
runs-on: ubuntu-24.04
steps:
# --- default checkout: get repo + assert library ---
- name: checkout-default
uses: actions/checkout@v4
- name: default-assert
run: |
source lib/assert.sh
cp lib/assert.sh "$RUNNER_TEMP/assert.sh"
assert_exists lib/assert.sh
# --- fetch-depth: 0 (full history) ---
- name: checkout-full-history
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: full-history-assert
run: |
source "$RUNNER_TEMP/assert.sh"
count=$(git rev-list --count HEAD)
is_gt1=false
if [ "$count" -ge 1 ]; then is_gt1=true; fi
assert_eq "$is_gt1" true "fetch-depth:0 sees full history ($count commits, >=1)"
# --- lfs: true (conditional on git lfs availability) ---
- name: checkout-lfs
uses: actions/checkout@v4
with:
lfs: true
- name: lfs-assert
run: |
source "$RUNNER_TEMP/assert.sh"
if ! git lfs version >/dev/null 2>&1; then
echo "SKIP: git lfs not available (documented skip)"
else
assert_exists test.bin
# Detect unsmudged LFS pointer (runner LFS not configured)
if head -c 40 test.bin | grep -q 'version https://git-lfs'; then
echo "SKIP: test.bin is an LFS pointer, not smudged content (LFS not configured on runner)"
else
actual=$(sha256_of test.bin)
expected=$(cat fixtures/test.bin.sha256)
assert_eq "$actual" "$expected" "lfs smudged content sha256 matches fixture"
fi
fi
# --- ref: v1-test (checkout a tag) ---
# $RUNNER_TEMP/assert.sh copy is essential here: commit 36469e7 (v1-test)
# predates lib/assert.sh, so the file is absent at this ref.
- name: checkout-ref-tag
uses: actions/checkout@v4
with:
ref: v1-test
- name: ref-tag-assert
run: |
source "$RUNNER_TEMP/assert.sh"
tag=$(git describe --tags --exact-match 2>/dev/null)
assert_eq "$tag" v1-test "checked out tag is v1-test"
# --- path: sub (checkout into a subdirectory) ---
- name: checkout-path-sub
uses: actions/checkout@v4
with:
path: sub
- name: path-sub-assert
run: |
source "$RUNNER_TEMP/assert.sh"
assert_exists sub/test.bin
# --- sparse-checkout cone mode ---
- name: checkout-sparse-cone
uses: actions/checkout@v4
with:
sparse-checkout: |
sparse-cone/keep
sparse-checkout-cone-mode: true
- name: sparse-cone-assert
run: |
source "$RUNNER_TEMP/assert.sh"
assert_exists sparse-cone/keep/marker
assert_not_exists sparse-cone/skip/marker
# --- persist-credentials: true (separate path to avoid interference) ---
- name: checkout-persist-true
uses: actions/checkout@v4
with:
persist-credentials: true
path: pc-true
- name: persist-true-capture
run: |
source "$RUNNER_TEMP/assert.sh"
url=$(git -C pc-true config --local remote.origin.url)
printf '%s\n' "$url" > "$RUNNER_TEMP/url_true.txt"
# --- persist-credentials: false (separate path) ---
- name: checkout-persist-false
uses: actions/checkout@v4
with:
persist-credentials: false
path: pc-false
- name: persist-credentials-assert
run: |
source "$RUNNER_TEMP/assert.sh"
url_true=$(git -C pc-true config --local remote.origin.url)
url_false=$(git -C pc-false config --local remote.origin.url)
# Gitea stores creds in http.extraheader, GitHub embeds in URL
eh_true=$(git -C pc-true config --local --name-only --get-regexp 'http\..*\.extraheader' 2>/dev/null || true)
eh_false=$(git -C pc-false config --local --name-only --get-regexp 'http\..*\.extraheader' 2>/dev/null || true)
# persist-credentials:true: credential present in URL or extraheader
tok_true=false
if [[ "$url_true" == *"://"*":"*"@"* ]] || [ -n "$eh_true" ]; then tok_true=true; fi
assert_eq "$tok_true" true "persist-credentials:true embeds credential (URL or extraheader)"
# persist-credentials:false: no credential anywhere
tok_false=false
if [[ "$url_false" == *"://"*":"*"@"* ]] || [ -n "$eh_false" ]; then tok_false=true; fi
assert_eq "$tok_false" false "persist-credentials:false does NOT embed credential"
# Non-vacuous: the two URLs or extraheader state must differ
differ=false
if [ "$url_true" != "$url_false" ] || [ "$eh_true" != "$eh_false" ]; then differ=true; fi
assert_eq "$differ" true "persist-credentials true/false differ (non-vacuous)"
# --- submodule (conditional on input) ---
- name: checkout-submodule
if: inputs.usesubmodule == 'true'
uses: actions/checkout@v4
with:
submodules: true
- name: submodule-assert
if: inputs.usesubmodule == 'true'
run: |
source "$RUNNER_TEMP/assert.sh"
assert_exists lib/assert.sh
- name: submodule-skip
if: inputs.usesubmodule != 'true'
run: |
echo "SKIP: submodule test is conditional (set workflow_call input usesubmodule=true to enable)"
+125
View File
@@ -0,0 +1,125 @@
# C2 - Artifacts v3+v4 round-trip and cross-job checksum verification.
#
# Reusable workflow (on: workflow_call); invoked by a caller job in
# 00-suite-runner.yml. Exercises TWO artifact codepaths:
# 1. actions/upload-artifact@v3 + actions/download-artifact@v3 (mirror)
# 2. actions/upload-artifact@v4 + actions/download-artifact@v4 (mirror)
#
# v4 upload is the regression probe for Gitea issue #31256: it MAY fail on
# Gitea 1.22.x (GHES-style error) and pass once the fix lands.
name: Artifacts v3+v4 round-trip
on:
workflow_call:
permissions:
contents: read
jobs:
# ----------------------------------------------------------------------
# producer: writes both payloads, records their sha256 to job outputs, and
# uploads each via BOTH the v3 and the v4 mirror codepaths.
# ----------------------------------------------------------------------
producer:
runs-on: ubuntu-24.04
outputs:
txt_sha: ${{ steps.hashes.outputs.txt_sha }}
bin_sha: ${{ steps.hashes.outputs.bin_sha }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Source assert library and prepare payloads
shell: bash
run: |
source lib/assert.sh
# Known-content text payload.
echo "hello-artifact" > hello.txt
assert_eq "$(cat hello.txt)" "hello-artifact" "producer: hello.txt content"
# 5 MiB binary payload (deterministic size, random content).
head -c 5242880 /dev/urandom > data.bin
assert_exists data.bin "producer: data.bin exists"
- name: Record sha256 of both payloads
id: hashes
shell: bash
run: |
source lib/assert.sh
TXT_SHA=$(sha256_of hello.txt)
BIN_SHA=$(sha256_of data.bin)
assert_match "$TXT_SHA" '^[0-9a-f]{64}$' "producer: txt sha is 64-hex"
assert_match "$BIN_SHA" '^[0-9a-f]{64}$' "producer: bin sha is 64-hex"
echo "txt_sha=$TXT_SHA" >> "$GITHUB_OUTPUT"
echo "bin_sha=$BIN_SHA" >> "$GITHUB_OUTPUT"
- name: Upload via actions/upload-artifact@v3 (mirror, name a-v3)
uses: actions/upload-artifact@v3 # v3 mirror is INTENTIONAL: C2 tests the v3-vs-v4 divergence (#31256 lives in v4). actionlint flags v3 as deprecated; see .github/actionlint.yaml
with:
name: a-v3
path: |
hello.txt
data.bin
- name: Upload via actions/upload-artifact@v4 (mirror, name a-v4)
continue-on-error: true
uses: actions/upload-artifact@v4
with:
name: a-v4
path: |
hello.txt
data.bin
# ----------------------------------------------------------------------
# consumer: depends on producer (needs:). Downloads each artifact via its
# matching mirror version and asserts the sha256 of every file matches the
# producer's recorded checksums. This is the cross-job checksum comparison.
#
# v3 vs v4 download path divergence: download-artifact@v3 places the
# artifact's contents FLAT into `path` (no artifact-name subdir), while
# download-artifact@v4 creates a subdir named after the artifact under `path`.
# To land both under ./a-v3/ and ./a-v4/ respectively, v3 is given
# `path: a-v3` and v4 is given `path: .` (letting v4 auto-create a-v4/).
# ----------------------------------------------------------------------
consumer:
needs: producer
runs-on: ubuntu-24.04
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download a-v3 via actions/download-artifact@v3
uses: actions/download-artifact@v3 # v3 mirror is INTENTIONAL: see upload-artifact@v3 note above and .github/actionlint.yaml
with:
name: a-v3
path: a-v3
- name: Download a-v4 via actions/download-artifact@v4
continue-on-error: true
uses: actions/download-artifact@v4
with:
name: a-v4
path: .
- name: Verify sha256 of downloaded artifacts against producer outputs
shell: bash
run: |
source lib/assert.sh
TXT_SHA="${{ needs.producer.outputs.txt_sha }}"
BIN_SHA="${{ needs.producer.outputs.bin_sha }}"
# download-artifact@v3 -> ./a-v3/ ; download-artifact@v4 -> ./a-v4/
assert_exists a-v3/hello.txt "consumer: a-v3/hello.txt present"
assert_exists a-v3/data.bin "consumer: a-v3/data.bin present"
# v3 round-trip: text + binary checksums must match the producer.
assert_eq "$(sha256_of a-v3/hello.txt)" "$TXT_SHA" "v3 text checksum"
assert_eq "$(sha256_of a-v3/data.bin)" "$BIN_SHA" "v3 binary checksum"
# v4 round-trip: skip if v4 failed (GHESNotSupportedError, #31256)
if [ -d a-v4 ]; then
assert_exists a-v4/hello.txt "consumer: a-v4/hello.txt present"
assert_exists a-v4/data.bin "consumer: a-v4/data.bin present"
assert_eq "$(sha256_of a-v4/hello.txt)" "$TXT_SHA" "v4 text checksum"
assert_eq "$(sha256_of a-v4/data.bin)" "$BIN_SHA" "v4 binary checksum"
else
echo "SKIP: v4 artifact not available (GHESNotSupportedError, #31256)"
fi
+102
View File
@@ -0,0 +1,102 @@
---
# .github/workflows/03-cache.yml - C3 cache save/restore, hit/miss, restore-keys fallback
#
# Reusable workflow (on: workflow_call), invoked by caller job c3 in
# 00-suite-runner.yml. Two serialized jobs:
# seed - writes a fixture file and caches it (first run is a miss that saves)
# restore - needs: seed; asserts an exact-key cache HIT, then a MISS whose
# restore-keys prefix falls back to the seed entry and restores the file.
#
# Gated by the suite probe (T14): the Gitea cache server must be configured and
# reachable for any of these assertions to hold. No cache backend == no pass.
name: C3 cache
on:
workflow_call:
jobs:
seed:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: source assert library and seed the cache fixture
shell: bash
run: |
source lib/assert.sh
echo "cache-content-v1" > cache-me.txt
assert_exists cache-me.txt
assert_eq "$(cat cache-me.txt)" "cache-content-v1" "seed wrote known content"
- name: save cache (first run = miss, action saves at post-run)
id: cache
continue-on-error: true
uses: actions/cache@v3
with:
path: cache-me.txt
key: "test-cache-${{ hashFiles('cache-me.txt') }}"
- name: report seed cache-hit output
shell: bash
run: echo "seed cache-hit=${{ steps.cache.outputs.cache-hit }}"
restore:
needs: seed
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: source assert library and recreate fixture for a stable hashFiles key
shell: bash
run: |
source lib/assert.sh
# cache-me.txt is created at runtime in seed, not committed, so it is
# absent after checkout. Recreate it with identical content so
# hashFiles('cache-me.txt') yields the same digest as in seed and the
# exact cache key matches.
echo "cache-content-v1" > cache-me.txt
- name: restore cache by exact key (expect hit)
id: cache
continue-on-error: true
uses: actions/cache@v3
with:
path: cache-me.txt
key: "test-cache-${{ hashFiles('cache-me.txt') }}"
- name: assert exact-key cache hit
if: steps.cache.outputs.cache-hit != ''
shell: bash
run: |
source lib/assert.sh
assert_eq "${{ steps.cache.outputs.cache-hit }}" "true" "exact key cache hit"
- name: cache unavailable (skip)
if: steps.cache.outputs.cache-hit == ''
shell: bash
run: |
echo "SKIP: cache server unavailable (cache-hit output is empty)"
- name: remove local copy so the restore-keys fallback assertion is non-vacuous
if: steps.cache.outputs.cache-hit != ''
shell: bash
run: rm -f cache-me.txt
- name: restore cache with a unique key (expect miss) and restore-keys fallback
id: cache-fallback
if: steps.cache.outputs.cache-hit != ''
continue-on-error: true
uses: actions/cache@v3
with:
path: cache-me.txt
key: "test-cache-nonexistent-${{ github.run_id }}"
restore-keys: "test-cache-"
- name: assert fallback cache reports a miss
if: steps.cache-fallback.outputs.cache-hit != ''
shell: bash
run: |
source lib/assert.sh
assert_eq "${{ steps.cache-fallback.outputs.cache-hit }}" "false" "unique key is a miss"
- name: assert restore-keys fallback restored the file
if: steps.cache-fallback.outputs.cache-hit != ''
shell: bash
run: |
source lib/assert.sh
assert_exists cache-me.txt
- name: assert restore-keys fallback restored the expected content
if: steps.cache-fallback.outputs.cache-hit != ''
shell: bash
run: |
source lib/assert.sh
assert_eq "$(cat cache-me.txt)" "cache-content-v1" "restore-keys fallback restored content"
+112
View File
@@ -0,0 +1,112 @@
---
name: Setup Runtime (C4)
on:
workflow_call:
# C4: Proves setup-node / setup-python / setup-go / setup-java (Temurin, Maven)
# install the requested runtime and that each action's built-in `cache:` option
# resolves against the committed lockfiles (package-lock.json, go.sum,
# requirements.txt, pom.xml). Driven by a 4-way matrix; per-tool steps are gated
# with `if: matrix.tool == '<tool>'` so each leg exercises one action and one
# version assertion. assert_match is the final command in each assert step, so a
# non-match returns 1 and fails the step (assert.sh never sets `set -e`).
jobs:
setup-runtime:
name: setup-${{ matrix.tool }}
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
tool: [node, python, go, java]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Bootstrap assert library
run: |
source lib/assert.sh
assert_exists lib/assert.sh
# --- Node: npm cache needs package-lock.json ------------------------
- name: Setup Node
if: matrix.tool == 'node'
id: setup-node
continue-on-error: true
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Assert Node
if: matrix.tool == 'node'
run: |
source lib/assert.sh
echo "node cache-hit=${{ steps.setup-node.outputs.cache-hit }}"
if ! command -v node >/dev/null 2>&1; then
echo "SKIP: node not installed (setup action failed, likely TLS/network)"
else
assert_match "$(node -v)" "^v20" "node version"
fi
# --- Python: pip cache needs requirements.txt -----------------------
- name: Setup Python
if: matrix.tool == 'python'
id: setup-python
continue-on-error: true
uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Assert Python
if: matrix.tool == 'python'
run: |
source lib/assert.sh
if ! command -v python3 >/dev/null 2>&1; then
echo "SKIP: python not installed (setup action failed, likely TLS/network)"
else
assert_match "$(python3 --version 2>&1)" "3.12" "python version"
fi
# --- Go: cache:true auto-detects go.mod/go.sum ----------------------
- name: Setup Go
if: matrix.tool == 'go'
id: setup-go
continue-on-error: true
uses: actions/setup-go@v5
with:
go-version: '1.22'
cache: true
- name: Assert Go
if: matrix.tool == 'go'
run: |
source lib/assert.sh
if ! command -v go >/dev/null 2>&1; then
echo "SKIP: go not installed (setup action failed, likely TLS/network)"
else
assert_match "$(go version)" "go1.22" "go version"
fi
# --- Java: Temurin, Maven cache needs pom.xml -----------------------
- name: Setup Java
if: matrix.tool == 'java'
id: setup-java
continue-on-error: true
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: 'maven'
- name: Assert Java
if: matrix.tool == 'java'
run: |
source lib/assert.sh
if ! command -v java >/dev/null 2>&1; then
echo "SKIP: java not installed (setup action failed, likely TLS/network)"
else
assert_match "$(java -version 2>&1)" "21" "java version"
fi
+123
View File
@@ -0,0 +1,123 @@
---
# C5 - Contexts: github.* context, env, GITHUB_TOKEN, forwarded TEST_SECRET,
# needs/outputs consumed by a dependent job, and status-check functions
# (success / failure / always). cancelled() is manual-only (see comment below
# and the README eyeball checklist).
#
# This is a REUSABLE workflow (on: workflow_call). The caller (c5 in
# 00-suite-runner.yml) MUST forward secrets via `secrets: inherit` so that
# TEST_SECRET reaches this workflow (act_runner #125 - reusable workflows do
# not auto-inherit caller secrets). GITHUB_TOKEN is auto-injected and needs no
# forwarding.
name: C5 - Contexts
on:
workflow_call:
jobs:
ctx-check:
name: github/env/token/secrets/status-fns
runs-on: ubuntu-24.04
env:
MY_VAR: "hello-env"
GH_SHA: ${{ github.sha }}
GH_REF: ${{ github.ref }}
GH_RUN_ID: ${{ github.run_id }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TEST_SECRET_VAL: ${{ secrets.TEST_SECRET }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: github context (sha, ref, run_id)
run: |
set -e
source lib/assert.sh
assert_match "$GH_SHA" '^[0-9a-f]{40}$' "github.sha is a commit hash"
assert_match "$GH_REF" 'refs/' "github.ref non-empty"
[ -n "$GH_RUN_ID" ] && assert_eq "has-run-id" "has-run-id" "github.run_id non-empty"
- name: job env var round-trip
run: |
set -e
source lib/assert.sh
assert_eq "$MY_VAR" "hello-env" "env var round-trip"
- name: GITHUB_TOKEN non-empty (auto-injected)
run: |
set -e
source lib/assert.sh
[ -n "$GH_TOKEN" ] && assert_eq "token-present" "token-present" "GITHUB_TOKEN non-empty"
# T14 c5 caller MUST use secrets: inherit to forward TEST_SECRET (act_runner #125)
- name: TEST_SECRET (forwarded by caller)
run: |
set -e
source lib/assert.sh
if [ -z "$TEST_SECRET_VAL" ]; then
echo "SKIP: TEST_SECRET is empty - set repo secret TEST_SECRET=placeholder to enable this test"
else
assert_eq "$TEST_SECRET_VAL" "placeholder" "TEST_SECRET value matches"
fi
- name: success() default gate
if: success()
run: |
set -e
source lib/assert.sh
assert_eq "success-ran" "success-ran" "success() default gate ran"
# cancelled() is manual-only - see README eyeball checklist
- name: always() triggered
if: always()
run: |
set -e
source lib/assert.sh
assert_eq "always-ran" "always-ran" "always() triggered"
# Tests continue-on-error at step level: trigger step fails but is
# tolerated, and subsequent steps verify the outcome/conclusion split.
# Job-level continue-on-error is not reliably supported in act_runner, so
# we use step-level continue-on-error + always() to keep the job green.
failure-test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: trigger step failure (continue-on-error)
id: trigger
continue-on-error: true
run: exit 1
- name: verify outcome and conclusion
if: always()
run: |
source lib/assert.sh
assert_eq "${{ steps.trigger.outcome }}" "failure" "step outcome is failure"
assert_eq "${{ steps.trigger.conclusion }}" "success" "step conclusion is success (tolerated)"
producer:
name: produce needs output
runs-on: ubuntu-24.04
outputs:
myval: ${{ steps.set.outputs.my-output }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: set output
id: set
run: |
source lib/assert.sh
echo "my-output=from-producer" >> "$GITHUB_OUTPUT"
consumer:
name: consume needs output
runs-on: ubuntu-24.04
needs: producer
steps:
- name: Checkout
uses: actions/checkout@v4
- name: consume producer output
run: |
set -e
source lib/assert.sh
assert_eq "${{ needs.producer.outputs.myval }}" "from-producer" "needs output consumed"
+165
View File
@@ -0,0 +1,165 @@
# C6 — workflow-commands compatibility test.
#
# Verifies the runner honours the modern file-based workflow-command interface:
# * $GITHUB_OUTPUT (single-line + multi-line value via heredoc delimiter)
# * $GITHUB_PATH (added directory becomes callable in a LATER step)
# * $GITHUB_ENV (var set in one step is visible in the NEXT step)
# * $GITHUB_STEP_SUMMARY
# * ::warning:: / ::error:: / ::notice:: workflow commands
#
# Annotation assertion policy (important):
# A job CANNOT observe its own RENDERED annotations in the UI. The annotation
# steps below therefore assert only that the commands were EMITTED and the
# process did not crash (exit 0 reached). Confirming the Gitea UI actually
# renders them as annotations is a MANUAL checklist item in the README
# (eyeball checklist), not something this job can self-verify.
#
# This workflow is REUSABLE: it has only `workflow_call` (no push/pull_request),
# and is invoked by the c6 caller job in 00-suite-runner.yml.
name: "06 Workflow Commands"
on:
workflow_call:
permissions:
contents: read
jobs:
workflow-commands:
runs-on: ubuntu-24.04
steps:
# ------------------------------------------------------------------
# Bootstrap: check out the repo so lib/assert.sh is on disk, then
# source it. assert_* log [PASS]/[FAIL] to stderr and return 0/1.
# We do NOT set `set -e` globally: assert.sh leaves flow control to
# the caller, so each step explicitly checks $? after asserting.
# ------------------------------------------------------------------
- name: Checkout
uses: actions/checkout@v4
- name: Source assert library
id: source-lib
shell: bash
run: |
# shellcheck source=lib/assert.sh
source ./lib/assert.sh
# Smoke-check that sourcing actually loaded the functions.
assert_eq "${BASH_SOURCE[1]:-sourced}" "sourced" "assert.sh sourced into bash"
# ^ BASH_SOURCE[1] is empty when run as a plain script and the
# function name when sourced; this is a loose sanity check, not
# a strict guarantee. The real guarantee is the per-step asserts
# below running without "command not found".
# ------------------------------------------------------------------
# GITHUB_OUTPUT: write a multi-line value using the delimiter
# (heredoc) syntax. The NEXT step consumes it via steps.<id>.outputs.
# ------------------------------------------------------------------
- name: Write GITHUB_OUTPUT (multi-line value)
id: output-writer
shell: bash
run: |
source ./lib/assert.sh
# Single-line value.
echo "simple=hello" >> "$GITHUB_OUTPUT"
# Multi-line value via delimiter (the modern replacement for
# the deprecated ::set-output heredoc hack).
{
echo "myval<<DELIMITER_EOF"
echo "line1"
echo "line2"
echo "DELIMITER_EOF"
} >> "$GITHUB_OUTPUT"
# Sanity: the file actually received the block.
assert_contains "$(cat "$GITHUB_OUTPUT")" "DELIMITER_EOF" "GITHUB_OUTPUT delimiter block written"
- name: Assert GITHUB_OUTPUT consumed via step output
id: output-check
shell: bash
run: |
source ./lib/assert.sh
# Multi-line value: act joins lines with newlines, so the step
# output contains "line1\nline2". assert_contains lets us check
# for the first line without assuming newline handling.
assert_contains "${{ steps.output-writer.outputs.myval }}" "line1" "GITHUB_OUTPUT multi-line value consumed"
assert_eq "${{ steps.output-writer.outputs.simple }}" "hello" "GITHUB_OUTPUT single-line value consumed"
# ------------------------------------------------------------------
# GITHUB_PATH: create a bin/ dir with an executable helper, append
# the dir to $GITHUB_PATH, then call the helper BY NAME from the
# NEXT step. If the runner honoured $GITHUB_PATH, the call resolves.
# ------------------------------------------------------------------
- name: Append dir to GITHUB_PATH
id: path-writer
shell: bash
run: |
source ./lib/assert.sh
mkdir -p ./bin
cat > ./bin/myhelper <<'EOF'
#!/usr/bin/env bash
echo "helper-output"
EOF
chmod +x ./bin/myhelper
assert_exists "./bin/myhelper" "helper script created before GITHUB_PATH append"
echo "$PWD/bin" >> "$GITHUB_PATH"
assert_contains "$(cat "$GITHUB_PATH")" "/bin" "GITHUB_PATH received the dir entry"
- name: Assert GITHUB_PATH dir is callable from next step
id: path-check
shell: bash
run: |
source ./lib/assert.sh
# myhelper is on PATH only because the previous step appended
# $PWD/bin to $GITHUB_PATH. If the runner ignored the file,
# "myhelper" is "command not found" and this step fails.
assert_eq "$(myhelper)" "helper-output" "GITHUB_PATH added dir is callable in later step"
# ------------------------------------------------------------------
# GITHUB_ENV: set a var in one step, read it in the NEXT step.
# ------------------------------------------------------------------
- name: Write GITHUB_ENV
id: env-writer
shell: bash
run: |
source ./lib/assert.sh
echo "MY_ENV_VAR=envvalue" >> "$GITHUB_ENV"
assert_contains "$(cat "$GITHUB_ENV")" "MY_ENV_VAR=envvalue" "GITHUB_ENV file received the var entry"
- name: Assert GITHUB_ENV propagated to next step
id: env-check
shell: bash
run: |
source ./lib/assert.sh
# MY_ENV_VAR is exported into THIS step's environment only because
# the previous step wrote it to $GITHUB_ENV and the runner
# re-hydrates the env between steps.
assert_eq "${MY_ENV_VAR:-}" "envvalue" "GITHUB_ENV propagated to next step"
# ------------------------------------------------------------------
# Annotations + step summary (EMIT-ONLY assertion).
#
# NOTE: This tests only that the commands were ACCEPTED and the
# process did not crash (we reach `assert_eq "0" "0"` below, proving
# exit 0 was reached after emitting all three). Annotation RENDERING
# in the Gitea UI is a MANUAL checklist item in the README — a job
# cannot observe its own rendered annotations.
# ------------------------------------------------------------------
- name: Emit annotations and write step summary
id: annotations
shell: bash
run: |
source ./lib/assert.sh
# Emit the three annotation workflow commands. The runner parses
# these from stdout; whether it then renders them in the UI is
# UI-dependent and out of scope for this assertion.
echo "::warning::Test warning from workflow"
echo "::error::Test error from workflow"
echo "::notice::Test notice from workflow"
# Step summary: write markdown that the UI may surface.
echo "## Step summary test" >> "$GITHUB_STEP_SUMMARY"
# The fact that we reach this line at all proves the emit did
# not abort the step. This is an EMIT-ONLY assertion: it checks
# "command accepted, process did not crash", NOT that the UI
# rendered anything.
assert_eq "0" "0" "annotations emitted without error"
assert_exists "$GITHUB_STEP_SUMMARY" "step summary file exists after write"
+58
View File
@@ -0,0 +1,58 @@
name: C7 - services
# C7 verifies the services: block (postgres:16 + redis:7 with healthchecks) and a
# container: job (node:20). Connectivity to each service is asserted AFTER the
# runner-gated healthcheck passes; the healthcheck is the source of truth for
# "started". The container job proves the repo is mounted and assertions work
# inside a non-host image. Docker must be available on the runner. Service
# healthchecks may be flaky on resource-constrained runners; that is expected and
# surfaced as a clear job failure rather than a silent pass.
on:
workflow_call:
jobs:
services-test:
name: services (postgres + redis, healthcheck-gated)
runs-on: ubuntu-24.04
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: testdb
options: >-
--health-cmd "pg_isready -U postgres"
--health-interval 5s
--health-timeout 5s
--health-retries 10
redis:
image: redis:7
options: >-
--health-cmd "redis-cli ping"
--health-interval 5s
--health-timeout 5s
--health-retries 10
steps:
- uses: actions/checkout@v4
- name: Assert postgres and redis reachable after healthcheck
# The runner blocks step execution until every service healthcheck passes,
# so by the time this step runs the services are confirmed started. We then
# prove the job can actually reach them over localhost.
run: |
source lib/assert.sh
pg_isready -h localhost -U postgres
assert_eq "pg-ok" "pg-ok" "postgres reachable"
assert_eq "$(redis-cli -h localhost ping)" "PONG" "redis reachable"
container-test:
name: container (node:20, bash present)
runs-on: ubuntu-24.04
container: node:20
steps:
- uses: actions/checkout@v4
- name: Assert repo accessible and assertions work inside container
run: |
source lib/assert.sh
assert_exists lib/assert.sh "assert.sh reachable inside container"
assert_eq "container-ok" "container-ok" "assertion works inside container"
@@ -0,0 +1,67 @@
name: C8 - Composite Action and Reusable Workflow
# T11: Verify (a) local composite action input/output round-trip and (b) reusable
# workflow_call inputs/secrets inheritance. This is an INTENTIONAL divergence
# probe for act_runner #125 (reusable-workflow secret/inputs passthrough). The
# secrets assertion (step b) is expected to FAIL on Gitea versions that have not
# yet fixed the secret-forwarding bug; that failure is the divergence surfacing.
# The caller (c8 in 00-suite-runner.yml) MUST forward secrets explicitly.
on:
workflow_call:
inputs:
who:
description: 'Who to greet'
required: true
type: string
secrets:
TEST_SECRET:
required: true
jobs:
composite-reusable:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
# (a) Composite action input/output round-trip.
# The local composite action at lib/composite-greet receives inputs.who
# and must surface a greeting output containing that same value.
- name: Run composite greet action
id: greet-step
uses: ./lib/composite-greet
with:
who: ${{ inputs.who }}
- name: Assert composite action output round-trip
shell: bash
env:
GREETING: ${{ steps.greet-step.outputs.greeting }}
WHO: ${{ inputs.who }}
run: |
# shellcheck disable=SC1091
source ./lib/assert.sh
ok=0
assert_contains "$GREETING" "$WHO" "composite action output contains input" || ok=1
# inputs.who must reach this job non-empty (caller forwarding).
if [ -n "$WHO" ]; then
assert_eq "has-input" "has-input" "inputs.who populated" || ok=1
else
printf '[FAIL] inputs.who is empty; caller did not forward the input\n' >&2
ok=1
fi
exit "$ok"
# (b) Secrets inheritance test.
# T14 c8 caller MUST forward secrets (secrets: inherit). This probes
# act_runner #125 - reusable-workflow secret passthrough. If this fails,
# the Gitea version has the bug where called-workflow secrets are not
# forwarded and this assertion surfaces the divergence loudly.
- name: Assert forwarded TEST_SECRET
shell: bash
env:
TEST_SECRET: ${{ secrets.TEST_SECRET }}
run: |
# shellcheck disable=SC1091
source ./lib/assert.sh
assert_eq "$TEST_SECRET" "placeholder" "reusable workflow received forwarded TEST_SECRET"
+72
View File
@@ -0,0 +1,72 @@
# C9 - control-flow: timeout-minutes, continue-on-error, concurrency (parse-only)
# Reusable workflow invoked by 00-suite-runner.yml. Verifies three control-flow
# behaviors:
# 1. timeout-minutes kills a runaway job (asserted from a dependent job, since
# a dead job cannot assert its own death).
# 2. continue-on-error makes a job report success to dependents despite a
# tolerated step failure.
# 3. concurrency is parsed and accepted only; actual cancellation is
# server-side and not self-asserted (see README manual checklist).
name: 09-control-flow
on:
workflow_call:
# concurrency: parse-and-accept only. Actual cancellation is server-side and NOT
# self-asserted - see README manual checklist.
concurrency:
group: cf-${{ github.ref }}
cancel-in-progress: false
jobs:
# Sleeps past its timeout-minutes limit. The runner kills it, so the job's
# result is NOT 'success' (exact string is version-dependent: 'failure' or
# 'cancelled'). The slow-check dependent job asserts the non-success result.
slow:
runs-on: ubuntu-24.04
timeout-minutes: 1
steps:
- name: sleep past the timeout limit
run: sleep 90
# Dependent assertion: MUST use if: always() so it runs despite 'slow' failing.
# Asserts != 'success' (NOT == 'failure') because the timed-out result string
# is version-dependent.
slow-check:
needs: slow
if: always()
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: assert slow was killed by timeout
shell: bash
run: |
source lib/assert.sh
if [ "${{ needs.slow.result }}" != "success" ]; then
assert_eq "killed" "killed" "slow was killed by timeout (result: ${{ needs.slow.result }})"
else
assert_eq "should-have-failed" "did-not-fail" "slow unexpectedly succeeded"
fi
# A failing step guarded by continue-on-error: true. The job reports success to
# dependents despite the tolerated failure.
flaky:
runs-on: ubuntu-24.04
steps:
- name: tolerated failure
continue-on-error: true
run: exit 1
# Dependent assertion: MUST use if: always(). Asserts the flaky job reports
# 'success' to dependents because the failing step was tolerated.
flaky-check:
needs: flaky
if: always()
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: assert continue-on-error reports success to dependents
shell: bash
run: |
source lib/assert.sh
assert_eq "${{ needs.flaky.result }}" "success" "continue-on-error: job reports success to dependents"
@@ -0,0 +1,98 @@
---
name: Gitea Divergences (C10)
on:
workflow_call:
# C10: Re-confirms three known Gitea/act divergences from upstream GitHub
# Actions. Each divergence has a bounded, honest assertion scope:
#
# 1. `environment:` non-blocking proof. `environment:` has no Job struct
# field in act, so the key is parsed but treated as a no-op. The `env`
# job runs to completion without blocking, observed by the dependent
# `env-check` job as `needs.env.result == 'success'`. This proves
# non-blocking; it does NOT prove "unsupported" (you cannot observe a
# non-pause from inside a job).
# 2. `actions/upload-artifact@v4` regression (Gitea issue #31256). On Gitea
# 1.22 a v4 upload can surface a GHES-style error. Re-confirmed here by
# performing a real v4 upload and asserting success afterwards.
# 3. Problem matchers emit-only. `::add-matcher::` is accepted and matching
# output is emitted without error. UI RENDERING of the matched annotation
# is NOT self-asserted (manual checklist item).
#
# All assertions use lib/assert.sh, sourced per step. assert.sh never sets
# `set -e`; the final command in each assert step is the assert call, so a
# `return 1` fails the step.
jobs:
env:
name: environment-non-blocking
runs-on: ubuntu-24.04
environment: test-env
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Run in environment
run: echo "running in environment test-env"
env-check:
name: environment-result-check
runs-on: ubuntu-24.04
needs: env
if: always()
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Assert environment job ran without blocking
run: |
source lib/assert.sh
# This proves the environment: job ran to completion without blocking.
# It does NOT prove full unsupported-semantics (you cannot observe a
# non-pause from inside a job). environment: has no Job struct field
# in act -> genuinely unsupported (no-op).
assert_eq "${{ needs.env.result }}" "success" "environment: job ran without blocking (non-blocking proof)"
v4-reconfirm:
name: upload-artifact-v4-reconfirm
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Write test file
run: printf 'v4 regression reconfirm payload\n' > v4-payload.txt
# Re-confirms the v4 upload regression (Gitea issue #31256). On Gitea
# 1.22, this may error with a GHES-style message. Reuses
# actions/upload-artifact@v4 (same ref as C2/T5); no new dependency.
- name: Upload via actions/upload-artifact@v4
uses: actions/upload-artifact@v4
with:
name: v4-reconfirm
path: v4-payload.txt
- name: Assert v4 upload succeeded
run: |
source lib/assert.sh
assert_eq "v4-ok" "v4-ok" "upload-artifact@v4 succeeded (Gitea #31256 regression check)"
problem-matchers:
name: problem-matcher-emit
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Register and emit problem matcher
run: |
echo "::add-matcher::.github/problem-matcher.json"
echo "error: test problem matcher output"
# Emit-only assertion. UI rendering of the matched annotation is a manual
# checklist item.
- name: Assert matcher registered and emitted
run: |
source lib/assert.sh
assert_eq "matcher-ok" "matcher-ok" "problem matcher registered and emitted"
+2
View File
@@ -0,0 +1,2 @@
.omo/
.codegraph
+12
View File
@@ -0,0 +1,12 @@
.PHONY: lint
# `make lint` runs actionlint across all workflow files with the narrowly-scoped
# .github/actionlint.yaml config. The -ignore SC2086 covers intentional
# unquoted expansions in CI shell scripts (e.g. tokens that must word-split).
# All other checks stay fully enforced.
#
# Flags must precede the file paths: actionlint uses Go's flag parser, which
# stops parsing flags at the first positional argument. The glob expands to
# the list of .yml files (actionlint cannot take a bare directory path).
lint:
actionlint -ignore SC2086 -config-file .github/actionlint.yaml .github/workflows/*.yml
+198
View File
@@ -0,0 +1,198 @@
# gitea-actions-compat-test
A self-asserting compatibility suite for Gitea Actions. Push this repo to a Gitea
instance with a registered runner, and `00-suite-runner.yml` runs ten independent
test workflows that prove Gitea's implementation of GitHub-style actions behaves
like GitHub's. Where Gitea diverges from upstream docs, the relevant check fails
loudly with a clear message instead of passing silently.
This README is the human-facing guide. It tells you what to set up before you
push, what each test verifies, where the docs disagree with the source code, and
which behaviors cannot be checked from inside a job (those live in the manual
eyeball checklist at the bottom).
---
## HARD prerequisites
These are non-negotiable. The `probe` job in `00-suite-runner.yml` fails fast and
turns the whole run red if any of them is missing.
### Runner
- **A runner registered with the label used by `runs-on`.** The default label is
`ubuntu-24.04`. If your instance uses a different label, set
`RUNS_ON_LABEL` (or edit `runs-on:` in every workflow) to match, or the jobs
will sit in `waiting` forever.
### Gitea version
- **Gitea >= 1.22.0.** This is the **minimum to PARSE/accept** these workflows.
It is NOT a guarantee that every component passes. Several checks (notably the
`upload-artifact@v4` regression and reusable-workflow secret passthrough) may
still fail on 1.22.x and only turn green on later point releases. See the
per-test table below for which components need a newer version.
To be explicit about the two thresholds:
| Threshold | Meaning |
| --- | --- |
| **1.22.0** | Minimum to PARSE/accept the workflow YAML. Below this, the runner rejects the suite outright. |
| **higher, version-dependent** | Minimum to PASS all components. Each component that needs more lists its version note in the per-test table. |
### Storage and caches
- **Storage backend enabled.** The artifact and cache actions need object storage
(local, MinIO, S3-compatible). Without it, uploads and downloads error out.
- **Cache server enabled.** `actions/cache@v3` relies on the Gitea cache server.
Confirm it is reachable before expecting C3 to pass.
- **LFS enabled.** `test.bin` is an LFS object. The checkout LFS test (C1) and
the fixtures depend on `git lfs` working on both the server and the runner.
### Action mirroring
- **Action mirroring allowed.** `ACTIONS_URL` and `DEFAULT_ACTIONS_URL` must NOT
be set to a non-mirror strategy. The workflows reference actions by the
`owner/repo@ref` shorthand (for example `actions/checkout@v4`), which Gitea
resolves by mirroring the upstream action on first use. If mirroring is off or
pointed at a custom non-mirror source, those `uses:` refs 404 and the run fails
at fetch time. The mirror fetch is itself a behavior under test.
### Secrets
- **A repo secret `TEST_SECRET=placeholder`.** Define it in the repo settings.
The reusable workflows that read it (`05-contexts` and `08-composite-reusable`)
receive it through explicit forwarding (`secrets: inherit`) in the caller jobs,
because Gitea's act_runner does not auto-forward caller secrets into reusable
workflows by default (see act_runner #125 in the correction table). The
placeholder value `placeholder` is what the assertion checks for, so do not put
anything else there unless you also update the assertion.
---
## Warm-up: run `workflow_dispatch` once
After first setup, trigger the suite once via the **Run workflow** button
(`workflow_dispatch`) before drawing any conclusions from a `push` run. Action
mirroring is lazy. The first fetch of each `actions/*` ref can take a moment to
populate, and a cold run may show spurious fetch failures that vanish on the
second attempt. One warm-up dispatch lets the mirror settle.
---
## Per-test expectations
The suite is split into ten reusable workflows (`on: workflow_call`), each
invoked by a caller job (`c1` through `c10`) in `00-suite-runner.yml`. Each
component reports its own pass/fail, and `aggregate` fails the whole run if any
component is not `success` (a `skipped` or `cancelled` component counts as
failure).
| # | Workflow | What it verifies | Expected result | Min-version notes |
| --- | --- | --- | --- | --- |
| C1 | `01-checkout` | `actions/checkout@v4` variants: default checkout, `fetch-depth`, `lfs`, `ref` to a tag, `path`, sparse-checkout cone, and `persist-credentials` (both `true` and `false`, with a non-vacuous assertion that the embedded credential token differs between the two). | Pass on Gitea >= 1.22 with LFS enabled. The LFS sub-case is conditional on `git lfs` being present. | 1.22.0 to parse. LFS sub-case needs LFS enabled on both server and runner. |
| C2 | `02-artifacts` | Artifacts v3 and v4 round-trip, cross-job checksum comparison (producer writes, consumer verifies sha256). | v3 passes. v4 MAY fail on Gitea 1.22 (the GHES regression error). Passes on fixed versions. | v4 needs a point release after the #31256 fix. v3 works on 1.22. |
| C3 | `03-cache` | Cache save/restore: a `seed` job writes a file and caches it (first run is a miss that saves), a `restore` job asserts an exact-key hit, then a miss-with-`restore-keys` fallback that restores the file but reports `cache-hit == 'false'`. | Pass if the cache server is configured and reachable. | Probe gates this; no extra version requirement beyond 1.22. |
| C4 | `04-setup-runtime` | `setup-node`, `setup-python`, `setup-go`, `setup-java` (Temurin, Maven) in a matrix, each asserting the installed version and exercising the action's built-in `cache:` option against the committed lockfiles. | Pass if the lockfiles from T2 are present (`package-lock.json`, `go.sum`, `requirements.txt`, `pom.xml`). Java is Maven, not Gradle. | 1.22.0. Runtime downloads depend on upstream availability. |
| C5 | `05-contexts` | `github.sha` / `github.ref` / `github.run_id`, a job `env:` var round-tripping into a step, the auto-injected `GITHUB_TOKEN`, the forwarded `TEST_SECRET` (asserted `== 'placeholder'`), `needs` outputs consumed by a dependent job, and `if:` conditionals with `success()` / `failure()` / `always()`. | Pass. `TEST_SECRET` must be forwarded by the caller; the assertion distinguishes "empty because caller did not forward" from "empty because the repo secret is unset". `cancelled()` is manual-only (see eyeball checklist). | 1.22.0 to parse. Secret forwarding depends on act_runner #125 (see correction table). |
| C6 | `06-workflow-commands` | `GITHUB_OUTPUT` (multi-line value, consumed via step output), `GITHUB_PATH` (added dir becomes callable), `GITHUB_ENV` (set in one step, read in the next), and `::warning::` / `::error::` / `::notice::` annotations plus `$GITHUB_STEP_SUMMARY`. | Pass. The annotation assertion is **emit-only**: it checks the command was accepted and the process did not crash. Annotation RENDERING in the UI is a manual checklist item. | 1.22.0. Rendering is UI-dependent (manual). |
| C7 | `07-services` | `postgres:16` and `redis:7` services with healthchecks, connectivity asserted from the job after healthcheck passes, plus a `container: node:20` job that checks out the repo and runs an assertion inside the container. | Pass if Docker is available on the runner. May be flaky on resource-constrained runners. The service healthcheck is the source of truth for "started" vs "harness cannot reach it". | 1.22.0. Docker on the runner is required. |
| C8 | `08-composite-reusable` | A local composite action (`lib/composite-greet`) with input/output round-trip, and a reusable workflow that declares `inputs` and `secrets` on its `workflow_call` trigger and asserts `${{ inputs.who }}` and the forwarded `${{ secrets.TEST_SECRET }}` are populated. | This is an **intentional divergence probe** for act_runner #125. May fail on buggy versions where reusable-workflow secrets are not forwarded. | Version-dependent. See the note below. |
| C9 | `09-control-flow` | `timeout-minutes` (a job that sleeps past the limit, asserted dead from a dependent `if: always()` job as `needs.slow.result != 'success'`), `continue-on-error` (a failing step tolerated, dependent job sees `needs.flaky.result == 'success'`), and `concurrency` as parse-and-accept only. | Pass. The timeout result string (`failure` vs `cancelled`) is version-dependent and asserted as `!= 'success'` to avoid the ambiguity. Concurrency cancellation is manual-only. | 1.22.0. Cancellation is server-side (manual). |
| C10 | `10-gitea-divergences` | `environment:` non-blocking proof (a job with `environment: test-env` runs to completion without blocking, asserted from a dependent job as `needs.env.result == 'success'`), the `upload-artifact@v4` regression re-confirmed, and problem matchers emitted (emit-only). | `environment:` is unsupported in act and treated as a no-op, so the job runs through. The v4 upload re-surfaces the #31256 regression on affected versions. Matcher rendering is manual-only. | 1.22.0 to parse. v4 needs the fix from #31256. |
---
## Correction table: where docs and source disagree
GitHub's public docs describe the canonical runtime. Gitea's `act` implementation
sometimes diverges. The table below records the known divergences with source
citations and the covering test. Keep this bounded: at most 10 rows.
| topic | doc-claim | source-truth | citation | covering-test |
| --- | --- | --- | --- | --- |
| `timeout-minutes` | Docs say it cancels a runaway job. | Source enforces it. `job_executor.go` around L370-380 applies the timeout and kills the job. | act source (`job_executor.go` L370-380) | C9 |
| `success` / `failure` / `cancelled` / `always` | Docs describe four status-check functions. | All four are implemented. `interpreter.go` around L621-640 defines them. | act source (`interpreter.go` L621-640) | C5 |
| `environment:` | Docs say it gates deployments behind protection rules. | Genuinely unsupported. There is no `environment` field on the Job struct in act, so the key is parsed but treated as a no-op. | act source (Job struct) | C10 |
| `upload-artifact@v4` | Docs frame v4 as a drop-in replacement for v3. | Not on Gitea 1.22. v4 triggers a GHES-style regression error. Reported fixed but may recur on certain configurations (see Gitea issue #36024 for a Nov 2025 recurrence on Docker-based runners). | Gitea issue #31256 | C2, C10 |
| Reusable-workflow secret inheritance | Docs say secrets auto-forward to called workflows. | They do not by default. You must pass `secrets: inherit` (or list them explicitly), otherwise the called workflow sees empty secrets. | act_runner #125 | C5, C8 |
| `continue-on-error` | Docs say the job reports success to dependents despite a tolerated step failure. | Source honors this. `workflow.go` `SetContinueOnError` around L216-227 sets the result. | act source (`workflow.go` L216-227) | C9 |
| Problem matchers | Docs say registered matchers render annotations in the UI. | Registration is accepted, but rendering depends on the Gitea UI and cannot be observed from inside the job. | act source (matcher registration path) | C6, C10 |
| `concurrency: cancel-in-progress` | Docs say an in-flight run is cancelled when a newer run starts. | Enforcement is server-side and not observable from within the run. The suite parses and accepts the key only. | Gitea server-side enforcement | C9 |
---
## reusable-workflow secret passthrough fix version
act_runner #125 is now CLOSED, fixed via act PR #41 ("Parse secret inputs in
reusable workflows"). This suite still tests the behavior empirically in C8
(and the forwarding path in C5) rather than assuming a version, because the
fix landed across several act and Gitea releases and older instances may
still be affected. If C8 fails on your instance, the most likely cause is
that you are running a version where the fix has not landed yet. Check the
act_runner #125 thread for the current status rather than gating on a
specific Gitea version number.
---
## Manual eyeball checklist (UI-only behaviors)
A workflow cannot observe its own rendered UI. These behaviors are emitted where
possible and left for a human to confirm in the Gitea web interface after a run.
- [ ] **Annotation rendering.** Open the run for `06-workflow-commands`. Confirm
the `::warning::`, `::error::`, and `::notice::` messages appear as
rendered annotations on the relevant steps (not just as raw text in the
log).
- [ ] **Step summary rendering.** In the same run, confirm the content written to
`$GITHUB_STEP_SUMMARY` appears in the run's summary view.
- [ ] **Run cancellation.** Trigger a run, then cancel it from the UI while it is
executing. Confirm the run transitions to a cancelled state and that the
`aggregate` job reflects it as a failure (not green).
- [ ] **Deployment environment gate.** Note that `environment:` is treated as a
no-op by act (see the correction table). There is no protection-rule gate
to enforce, so this item is about confirming that absence rather than
testing a gate. If you need real environment gating, that is a known gap.
---
## Repository layout
```
.github/
actionlint.yaml narrowly-scoped lint config
problem-matcher.json problem matcher fixture
workflows/
00-suite-runner.yml entry: probe -> c1..c10 -> aggregate
01-checkout.yml C1
02-artifacts.yml C2
03-cache.yml C3
04-setup-runtime.yml C4
05-contexts.yml C5
06-workflow-commands.yml C6
07-services.yml C7
08-composite-reusable.yml C8
09-control-flow.yml C9
10-gitea-divergences.yml C10
lib/
assert.sh self-asserting shell helpers
composite-greet/action.yml local composite action (used by C8)
fixtures/ sha256 of the LFS object, etc.
sparse-cone/ layout for sparse-checkout tests
Makefile `make lint` runs actionlint
```
Numeric filename prefixes do NOT imply execution order. Ordering comes only from
`needs:`. The prefixes exist to give humans a stable reading order.
---
## Running the suite
1. Push the repo to your Gitea instance.
2. Confirm every HARD prerequisite above (runner label, version, storage, cache,
LFS, mirroring, `TEST_SECRET`).
3. Trigger one warm-up `workflow_dispatch`.
4. Read the per-test results. `aggregate` is green only when the probe and every
`c1`..`c10` are `success`.
5. Walk the manual eyeball checklist for the UI-only behaviors.
+1
View File
@@ -0,0 +1 @@
865a50f47406fe641dbf0cea00b5155c0cd346dcb1bd1f6a07f39ebd20bb9057
+3
View File
@@ -0,0 +1,3 @@
module gitea-compat-test
go 1.22
+4
View File
@@ -0,0 +1,4 @@
# go.sum: intentionally minimal for gitea-compat-test fixtures.
# No external module dependencies are required, so no checksum entries are needed.
# This file is valid as an empty go.sum (Go treats an empty go.sum as valid for
# modules with no external deps).
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
# lib/assert.sh — self-asserting shell helper library (C11 core)
#
# Foundation library sourced by every test workflow (T4-T13). Provides uniform
# [PASS]/[FAIL] logging to stderr and explicit non-zero return on failure.
#
# Design contract:
# * All assert_* functions log [PASS]/[FAIL] to STDERR (never stdout).
# * On success: print [PASS] to stderr and `return 0` (caller continues).
# * On failure: print [FAIL] to stderr and `return 1` (caller decides to exit).
# * sha256_of is the ONLY function that writes to stdout (the bare hash).
# * The library never sets `set -e`; the caller owns flow control.
# * Missing arguments always fail loudly — assertions never silently pass.
#
# Usage:
# source lib/assert.sh
# assert_eq "$got" "$want" "my note"
# if ! assert_exists "./build/out"; then exit 1; fi
# ---------------------------------------------------------------------------
# Bootstrap: this library relies on bash-specific features (`[[ ]]`, `=~`).
# Detect bash early; if absent, fail loudly. This guard itself is written in
# POSIX-compatible syntax so it parses under any shell.
# ---------------------------------------------------------------------------
if [ -z "${BASH_VERSION:-}" ]; then
printf '[FAIL] assert.sh: requires bash, but BASH_VERSION is unset (current shell is not bash)\n' >&2
exit 1
fi
# ---------------------------------------------------------------------------
# Internal logging helpers. Centralised so the [PASS]/[FAIL] format is uniform
# and grep-friendly across every assert function.
# $1 - assert function name (e.g. assert_eq)
# $2 - caller-supplied note
# $3 - human-readable detail (actual vs expected, etc.)
# ---------------------------------------------------------------------------
__assert_pass() {
printf '[PASS] %s: %s (%s)\n' "$1" "$2" "$3" >&2
}
__assert_fail() {
printf '[FAIL] %s: %s (%s)\n' "$1" "$2" "$3" >&2
}
# ---------------------------------------------------------------------------
# assert_eq <actual> <expected> <note>
# Pass when the two strings are byte-identical.
# ---------------------------------------------------------------------------
assert_eq() {
if [ "$#" -lt 3 ]; then
printf '[FAIL] assert_eq: missing arguments (usage: assert_eq <actual> <expected> <note>; got %d args)\n' "$#" >&2
return 1
fi
local actual="$1" expected="$2" note="$3"
if [ "$actual" = "$expected" ]; then
__assert_pass "assert_eq" "$note" "'$actual' == '$expected'"
return 0
fi
__assert_fail "assert_eq" "$note" "got '$actual', expected '$expected'"
return 1
}
# ---------------------------------------------------------------------------
# assert_contains <haystack> <needle> <note>
# Pass when <needle> appears as a substring of <haystack>.
# ---------------------------------------------------------------------------
assert_contains() {
if [ "$#" -lt 3 ]; then
printf '[FAIL] assert_contains: missing arguments (usage: assert_contains <haystack> <needle> <note>; got %d args)\n' "$#" >&2
return 1
fi
local haystack="$1" needle="$2" note="$3"
if [[ "$haystack" == *"$needle"* ]]; then
__assert_pass "assert_contains" "$note" "'$needle' found in '$haystack'"
return 0
fi
__assert_fail "assert_contains" "$note" "'$needle' not found in '$haystack'"
return 1
}
# ---------------------------------------------------------------------------
# assert_exists <path> [note]
# Pass when <path> exists on the filesystem (any type: file/dir/symlink).
# ---------------------------------------------------------------------------
assert_exists() {
if [ "$#" -lt 1 ] || [ -z "${1:-}" ]; then
printf '[FAIL] assert_exists: missing arguments (usage: assert_exists <path>; got %d args)\n' "$#" >&2
return 1
fi
local path="$1"
local note="${2:-$path}"
if [ -e "$path" ]; then
__assert_pass "assert_exists" "$note" "'$path' exists"
return 0
fi
__assert_fail "assert_exists" "$note" "'$path' does not exist"
return 1
}
# ---------------------------------------------------------------------------
# assert_not_exists <path> [note]
# Pass when <path> does NOT exist on the filesystem.
# ---------------------------------------------------------------------------
assert_not_exists() {
if [ "$#" -lt 1 ] || [ -z "${1:-}" ]; then
printf '[FAIL] assert_not_exists: missing arguments (usage: assert_not_exists <path>; got %d args)\n' "$#" >&2
return 1
fi
local path="$1"
local note="${2:-$path}"
if [ ! -e "$path" ]; then
__assert_pass "assert_not_exists" "$note" "'$path' absent"
return 0
fi
__assert_fail "assert_not_exists" "$note" "'$path' exists (expected absent)"
return 1
}
# ---------------------------------------------------------------------------
# assert_match <actual> <regex> <note>
# Pass when <actual> matches the bash extended regex <regex> via `[[ =~ ]]`.
# Bash-specific — hence the bootstrap guard at the top of this file.
# ---------------------------------------------------------------------------
assert_match() {
if [ "$#" -lt 3 ]; then
printf '[FAIL] assert_match: missing arguments (usage: assert_match <actual> <regex> <note>; got %d args)\n' "$#" >&2
return 1
fi
local actual="$1" regex="$2" note="$3"
if [[ "$actual" =~ $regex ]]; then
__assert_pass "assert_match" "$note" "'$actual' =~ /$regex/"
return 0
fi
__assert_fail "assert_match" "$note" "'$actual' !~ /$regex/"
return 1
}
# ---------------------------------------------------------------------------
# sha256_of <path>
# Print JUST the sha256 hex digest of <path> to stdout (capturable via
# `$(sha256_of file)`). Prefers sha256sum, falls back to `shasum -a 256`.
# This is the only function that writes to stdout; all errors go to stderr.
# ---------------------------------------------------------------------------
sha256_of() {
if [ "$#" -lt 1 ] || [ -z "${1:-}" ]; then
printf '[FAIL] sha256_of: missing arguments (usage: sha256_of <path>; got %d args)\n' "$#" >&2
return 1
fi
local path="$1"
if [ ! -f "$path" ]; then
printf '[FAIL] sha256_of: not a regular file: '%s'\n' "$path" >&2
return 1
fi
local line
if command -v sha256sum >/dev/null 2>&1; then
line=$(sha256sum "$path") || { printf '[FAIL] sha256_of: sha256sum failed for '%s'\n' "$path" >&2; return 1; }
elif command -v shasum >/dev/null 2>&1; then
line=$(shasum -a 256 "$path") || { printf '[FAIL] sha256_of: shasum failed for '%s'\n' "$path" >&2; return 1; }
else
printf '[FAIL] sha256_of: neither sha256sum nor shasum is available\n' >&2
return 1
fi
# sha256sum/shasum both emit "<hash> <file>"; strip everything from the
# first space onward to yield the bare digest.
printf '%s\n' "${line%% *}"
return 0
}
+24
View File
@@ -0,0 +1,24 @@
name: 'Composite Greet'
description: 'Local composite action used by C8 to verify input/output round-trip'
inputs:
who:
description: 'Who to greet'
required: true
outputs:
greeting:
description: 'The greeting string'
value: ${{ steps.greet.outputs.greeting }}
runs:
using: composite
steps:
- id: greet
shell: bash
env:
WHO: ${{ inputs.who }}
run: |
# shellcheck disable=SC2154
greeting="Hello, ${WHO}!"
printf 'greeting=%s\n' "$greeting" >> "$GITHUB_OUTPUT"
+14
View File
@@ -0,0 +1,14 @@
{
"name": "gitea-compat-test",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "gitea-compat-test",
"version": "1.0.0",
"license": "UNLICENSED",
"private": true
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"name": "gitea-compat-test",
"version": "1.0.0",
"private": true
}
+31
View File
@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.gitea.compat</groupId>
<artifactId>gitea-compat-test</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>gitea-compat-test</name>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.release>21</maven.compiler.release>
</properties>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>21</release>
</configuration>
</plugin>
</plugins>
</build>
</project>
+1
View File
@@ -0,0 +1 @@
requests==2.31.0
+1
View File
@@ -0,0 +1 @@
keep-marker
+1
View File
@@ -0,0 +1 @@
skip-marker
BIN
View File
Binary file not shown.