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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/actions/checkout-release-source/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: Checkout complete release source
description: Avoid unshallowing a reused CI checkout before fetching release history.
inputs:
ref:
description: Exact source ref or commit accepted by the calling release job.
default: ''
fetch-depth:
description: History depth; release jobs require complete history.
default: '0'
persist-credentials:
description: Preserve the calling job's existing checkout credential policy.
default: 'true'
runs:
using: composite
steps:
- name: Retire only the inherited shallow CI Git metadata
uses: actions/github-script@v9
env:
ZSTANDARD_CHECKOUT_BOOTSTRAP: ${{ github.action_path }}/retire-shallow-cache.cjs
with:
script: return require(process.env.ZSTANDARD_CHECKOUT_BOOTSTRAP)({core});
- uses: actions/checkout@v6
with:
ref: ${{ inputs.ref }}
fetch-depth: ${{ inputs.fetch-depth }}
persist-credentials: ${{ inputs.persist-credentials }}
64 changes: 64 additions & 0 deletions .github/actions/checkout-release-source/retire-shallow-cache.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
'use strict';

const fs = require('node:fs');
const path = require('node:path');
const {execFileSync} = require('node:child_process');

module.exports = function retireShallowCache({core, env = process.env}) {
const repository = env.GITHUB_REPOSITORY || '';
if (env.GITHUB_ACTIONS !== 'true' || !/^[\w.-]+\/[\w.-]+$/.test(repository)) {
throw new Error('Release checkout requires a GitHub Actions repository workspace.');
}
const workspace = fs.realpathSync.native(env.GITHUB_WORKSPACE);
const temporary = fs.realpathSync.native(env.RUNNER_TEMP);
const name = repository.split('/')[1];
const expected = path.join(path.dirname(temporary), name, name);
const equal = (a, b) => process.platform === 'win32'
? a.toLowerCase() === b.toLowerCase() : a === b;
if (!equal(workspace, expected) || fs.lstatSync(env.GITHUB_WORKSPACE).isSymbolicLink()) {
throw new Error('Refusing to alter Git metadata outside the runner-owned repository workspace.');
}
const metadata = path.join(workspace, '.git');
let information;
try { information = fs.lstatSync(metadata); }
catch (error) { if (error.code === 'ENOENT') return {retired: false}; throw error; }
if (!information.isDirectory() || information.isSymbolicLink()) {
throw new Error('Refusing a linked Git directory or worktree.');
}
const git = (...args) => execFileSync('git', ['-C', workspace, ...args], {
env: {...env, GIT_OPTIONAL_LOCKS: '0'}, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
}).trim();
if (git('rev-parse', '--is-shallow-repository') !== 'true') return {retired: false};
const origin = new URL(git('config', '--get', 'remote.origin.url'));
if (origin.protocol !== 'https:' || origin.hostname !== 'github.com'
|| origin.username || origin.password
|| origin.pathname.replace(/\.git$/, '').replace(/^\//, '').toLowerCase() !== repository.toLowerCase()) {
throw new Error('The shallow cache does not belong to the calling GitHub repository.');
}
for (const name of ['worktrees', 'modules']) {
const directory = path.join(metadata, name);
if (fs.existsSync(directory) && fs.readdirSync(directory).length) {
throw new Error('Retaining Git metadata with registered worktrees or submodules.');
}
}
for (const name of ['index.lock', 'shallow.lock', 'HEAD.lock']) {
if (fs.existsSync(path.join(metadata, name))) throw new Error('Retaining locked Git metadata.');
}
try { git('diff', '--quiet'); git('diff', '--cached', '--quiet'); }
catch { throw new Error('Could not verify clean tracked source; retaining the CI Git metadata.'); }
if (git('ls-files', '--others', '--exclude-standard') || git('for-each-ref', '--format=%(objectname)', 'refs/stash')) {
throw new Error('Retaining unexpected untracked files or stashed work in the CI workspace.');
}
const publicRefs = new Set(git('for-each-ref', '--format=%(objectname)', 'refs/remotes', 'refs/tags').split('\n'));
const branches = git('for-each-ref', '--format=%(objectname)', 'refs/heads').split('\n').filter(Boolean);
if (branches.some(oid => !publicRefs.has(oid))) {
throw new Error('Retaining a local branch that is not represented by the cached remote refs.');
}
const backup = fs.mkdtempSync(path.join(temporary, 'zstandard-shallow-git-'));
fs.chmodSync(backup, 0o700);
fs.writeFileSync(path.join(backup, '.owner'), JSON.stringify({repository, run: env.GITHUB_RUN_ID,
attempt: env.GITHUB_RUN_ATTEMPT, head: git('rev-parse', 'HEAD')}), {mode: 0o600});
fs.renameSync(metadata, path.join(backup, 'git'));
core.notice('Retired shallow CI Git metadata to private job temporary storage; checkout will fetch complete history.');
return {retired: true};
};
13 changes: 11 additions & 2 deletions .github/actions/preserve-flutter-project-files/action.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
name: Preserve Flutter project files
description: Preserve files that Flutter may rewrite during dependency resolution.
description: Preserve analysis options and tracked Apple host files that Flutter may rewrite.

inputs:
package:
Expand All @@ -12,7 +12,7 @@ inputs:
runs:
using: composite
steps:
- name: Save or restore analysis options
- name: Save or restore Flutter project files
shell: bash
env:
PACKAGE: ${{ inputs.package }}
Expand All @@ -21,6 +21,14 @@ runs:
set -euo pipefail
BACKUP_DIR="$RUNNER_TEMP/flutter-project-files/${PACKAGE//\//_}"
files=("analysis_options.yaml" "example/analysis_options.yaml")
case "$PACKAGE" in
zstandard_ios|zstandard_macos)
platform="${PACKAGE#zstandard_}"
while IFS= read -r -d '' file; do
files+=("${file#"$PACKAGE/"}")
done < <(git ls-files -z -- "$PACKAGE/example/$platform/Runner.xcodeproj")
;;
esac

if [[ "$MODE" == 'save' ]]; then
mkdir -p "$BACKUP_DIR"
Expand All @@ -33,6 +41,7 @@ runs:
elif [[ "$MODE" == 'restore' ]]; then
for file in "${files[@]}"; do
if [[ -f "$BACKUP_DIR/$file" ]]; then
mkdir -p "$PACKAGE/$(dirname "$file")"
cp "$BACKUP_DIR/$file" "$PACKAGE/$file"
fi
done
Expand Down
38 changes: 38 additions & 0 deletions .github/actions/publish-package/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: Publish one immutable package
description: Renew OIDC for each package and resolve only the distributable package.
inputs:
package:
required: true
description: Name of the matched release package.
version:
required: true
description: Accepted stable release version.
dart-version:
required: true
description: Exact Dart SDK version bundled with the tested Flutter SDK.
runs:
using: composite
steps:
# Each package gets a fresh credential after the previous indexing wait.
- uses: dart-lang/setup-dart@v1
with:
sdk: ${{ inputs.dart-version }}
- name: Resolve, publish and wait for the exact version
shell: bash
env:
ZSTANDARD_PUBLISH_PACKAGE: ${{ inputs.package }}
ZSTANDARD_PUBLISH_VERSION: ${{ inputs.version }}
run: |
set -euo pipefail
case "$ZSTANDARD_PUBLISH_PACKAGE" in
zstandard|zstandard_platform_interface|zstandard_native|zstandard_android|zstandard_ios|zstandard_linux|zstandard_macos|zstandard_web|zstandard_windows|zstandard_cli) ;;
*) echo 'Unknown release package.' >&2; exit 2 ;;
esac
case "$ZSTANDARD_PUBLISH_PACKAGE" in
zstandard_native|zstandard_cli)
(cd "$ZSTANDARD_PUBLISH_PACKAGE" && dart pub get --no-example) ;;
*)
(cd "$ZSTANDARD_PUBLISH_PACKAGE" && flutter pub get --no-example) ;;
esac
dart run scripts/publish_if_missing.dart "$ZSTANDARD_PUBLISH_PACKAGE" "$ZSTANDARD_PUBLISH_VERSION"
dart run scripts/wait_for_pub_version.dart "$ZSTANDARD_PUBLISH_PACKAGE" "$ZSTANDARD_PUBLISH_VERSION" 1200
2 changes: 1 addition & 1 deletion .github/scripts/check_action_versions.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def main(root):
for path in paths:
for reference in references(yaml.safe_load(path.read_text(encoding="utf-8"))):
count += 1
if isinstance(reference, str) and reference.startswith("./"):
if isinstance(reference, str) and reference.startswith(("./", "$/.github/actions/")):
continue
if not isinstance(reference, str) or "@" not in reference or not VERSION.fullmatch(reference.rsplit("@", 1)[-1]):
errors.append(f"{path.relative_to(root)}: {reference}: use a version tag such as @v1")
Expand Down
117 changes: 117 additions & 0 deletions .github/scripts/tests/test_flutter_project_files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"""Run real save/restore steps against tracked Apple host fixtures without Flutter."""

import os
from pathlib import Path
import subprocess
import tempfile
import unittest

import yaml

ROOT = Path(__file__).resolve().parents[3]
ACTION = './.github/actions/preserve-flutter-project-files'


class FlutterProjectFilesTests(unittest.TestCase):
def setUp(self):
self.folder = tempfile.TemporaryDirectory(prefix='zstd-project-files-')
self.addCleanup(self.folder.cleanup)
self.root = Path(self.folder.name) / 'workspace with spaces'
self.root.mkdir()
self.temporary = Path(self.folder.name) / 'private state'
self.temporary.mkdir()
self.environment = {key: value for key, value in os.environ.items()
if not key.startswith('GIT_')}
self.environment.update(RUNNER_TEMP=str(self.temporary), BASH_ENV=os.devnull,
GIT_CONFIG_GLOBAL=os.devnull, GIT_CONFIG_NOSYSTEM='1')
self.git('init', '-q')
action = yaml.safe_load((ROOT / ACTION.removeprefix('./') / 'action.yml').read_text())
self.script = action['runs']['steps'][0]['run']

def git(self, *args):
subprocess.run(['git', *args], cwd=self.root, env=self.environment,
capture_output=True, check=True, timeout=10)

def write(self, name, content):
path = self.root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
return path

def run_action(self, package, mode):
result = subprocess.run(['bash', '-e', '-o', 'pipefail', '-c', self.script],
cwd=self.root, env={**self.environment, 'PACKAGE': package,
'MODE': mode}, capture_output=True,
text=True, timeout=10)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)

def test_apple_migrations_restore_exact_starting_files_and_preserve_other_work(self):
for platform in ['ios', 'macos']:
with self.subTest(platform=platform):
package = 'zstandard_' + platform
project = f'{package}/example/{platform}/Runner.xcodeproj'
paths = [f'{package}/analysis_options.yaml',
f'{package}/example/analysis_options.yaml',
project + '/project.pbxproj',
project + '/xcshareddata/xcschemes/Runner.xcscheme',
project + '/xcshareddata/settings with spaces.xml']
for name in paths:
self.write(name, 'original ' + name)
self.git('add', '--', package)
# Preserve the actual starting worktree, including authored edits,
# rather than replacing it with an assumed HEAD/index baseline.
self.write(paths[2], 'authored baseline before Flutter ran')
originals = {name: (self.root / name).read_bytes() for name in paths}
self.run_action(package, 'save')
for name in paths:
self.write(name, 'Flutter migration changed ' + name)
scheme = self.root / paths[3]
scheme.unlink()
scheme.parent.rmdir()
untracked = self.write(project + '/untracked-note.txt', 'retain untracked work')
unrelated = self.write(package + '/lib/authored.dart', 'retain unrelated work')
self.run_action(package, 'restore')
for name, content in originals.items():
self.assertEqual((self.root / name).read_bytes(), content)
self.assertEqual(untracked.read_text(), 'retain untracked work')
self.assertEqual(unrelated.read_text(), 'retain unrelated work')

def test_restore_without_a_snapshot_leaves_source_untouched(self):
path = self.write('zstandard_ios/analysis_options.yaml', 'keep me')
self.git('add', '--', 'zstandard_ios')
self.run_action('zstandard_ios', 'restore')
self.assertEqual(path.read_text(), 'keep me')
self.assertFalse((self.temporary / 'flutter-project-files').exists())

def test_non_apple_packages_keep_the_existing_analysis_only_scope(self):
analysis = self.write('zstandard_web/analysis_options.yaml', 'original analysis')
source = self.write('zstandard_web/lib/source.dart', 'original source')
self.git('add', '--', 'zstandard_web')
self.run_action('zstandard_web', 'save')
analysis.write_text('Flutter analysis rewrite')
source.write_text('authored source')
self.run_action('zstandard_web', 'restore')
self.assertEqual(analysis.read_text(), 'original analysis')
self.assertEqual(source.read_text(), 'authored source')

def test_apple_jobs_restore_after_integration_even_when_a_step_fails(self):
for workflow, job, integration in [
('pr_check_ios.yml', 'check_ios_test', 'Run integration tests'),
('pr_check_macos.yml', 'check_macos_test',
'Launch macOS example and run integration tests'),
('release_workflow.yml', 'candidate_apple', 'Run Apple integration tests'),
]:
with self.subTest(workflow=workflow):
data = yaml.safe_load((ROOT / '.github/workflows' / workflow).read_text())
steps = data['jobs'][job]['steps']
integration_index = next(i for i, step in enumerate(steps)
if step.get('name') == integration)
restore_index = max(i for i, step in enumerate(steps)
if step.get('uses') == ACTION
and step['with']['mode'] == 'restore')
self.assertGreater(restore_index, integration_index)
self.assertEqual(steps[restore_index]['if'], 'always()')


if __name__ == '__main__':
unittest.main()
Loading
Loading