Files
prathish D 6cf5d80be8 feat: embed build metadata in generated dotslash files (#13)
* feat: embed build metadata in generated dotslash files

- Add build metadata collection from GitHub Actions environment
- Include source config path, CI information, and generation timestamp
- Add CI job URL for traceability back to the generating workflow
- Support disabling metadata via include-build-metadata input
- Update action.yml to expose new input parameter
- Add comprehensive documentation with examples

Fixes #2

* feat: pass include-build-metadata as argument instead of environment variable

- Remove environment variable approach from action.yml
- Pass include-build-metadata as command-line argument
- Update argument parsing to handle string values ('true'/'false')
- Maintain backward compatibility with environment variable fallback

Addresses review feedback from @bigfootjon in PR #13
2025-11-17 09:33:11 -08:00

544 lines
18 KiB
Python
Executable File

#!/usr/bin/env python3
# Copyright (c) Meta Platforms, Inc. and affiliates.
#
# This source code is licensed under the MIT license found in the
# LICENSE file in the root directory of this source tree.
import argparse
import json
import logging
import os
import re
import subprocess
import sys
import tempfile
import datetime
from functools import cache
from typing import Any, Dict, Literal, Optional, Tuple, Union
HashAlgorithm = Literal["blake3", "sha256"]
ArtifactFormat = Literal["gz", "tar", "tar.gz", "tar.zst", "zst", "tar.xz", "xz", "zip"]
# Recognized properties in the JSON config.
OUTPUTS_PARAM = "outputs"
EXCLUDE_HTTP_PROVIDER_PARAM = "exclude-http-provider"
EXCLUDE_GITHUB_PROVIDER_PARAM = "exclude-github-release-provider"
def collect_build_metadata(config_path: str) -> Dict[str, Any]:
"""Collect build metadata from GitHub Actions environment variables."""
metadata = {}
# Source file information
if config_path:
metadata["source_config"] = config_path
# CI/GitHub Actions information
github_env_vars = [
("github_repository", "GITHUB_REPOSITORY"),
("github_ref", "GITHUB_REF"),
("github_sha", "GITHUB_SHA"),
("github_run_id", "GITHUB_RUN_ID"),
("github_run_number", "GITHUB_RUN_NUMBER"),
("github_workflow", "GITHUB_WORKFLOW"),
("github_actor", "GITHUB_ACTOR"),
("github_event_name", "GITHUB_EVENT_NAME"),
("github_server_url", "GITHUB_SERVER_URL"),
]
ci_info = {}
for key, env_var in github_env_vars:
value = os.getenv(env_var)
if value:
ci_info[key] = value
if ci_info:
metadata["ci"] = ci_info
# Build timestamp
metadata["generated_at"] = datetime.datetime.utcnow().isoformat() + "Z"
# Add link to the CI job if we have the necessary info
if all(os.getenv(var) for var in ["GITHUB_SERVER_URL", "GITHUB_REPOSITORY", "GITHUB_RUN_ID"]):
job_url = f"{os.getenv('GITHUB_SERVER_URL')}/{os.getenv('GITHUB_REPOSITORY')}/actions/runs/{os.getenv('GITHUB_RUN_ID')}"
metadata["ci_job_url"] = job_url
return metadata
def main() -> None:
exit_code = _main()
sys.exit(exit_code)
def _main() -> int:
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
handlers=[logging.StreamHandler()],
)
args = parse_args()
repo: str = args.repo
if not repo:
raise ValueError(
"no repo specified: must specify --repo or set the GITHUB_REPOSITORY environment variable"
)
output_folder = args.output
if not output_folder:
output_folder = tempfile.mkdtemp(prefix=f"{repo.replace('/', '_')}_dotslash")
logging.info(f"DotSlash files will be written to `{output_folder}")
tag = args.tag
github_server_url = args.server
api_server_url = args.api_server
gh_repo_arg = f"{github_server_url}/{repo}"
if args.local_config:
print(args.config)
with open(args.config, "r") as f:
config = json.load(f)
else:
config = get_config(
path_to_config=args.config,
config_ref=args.config_ref,
github_repository=repo,
api_url=api_server_url,
)
if not isinstance(config, dict):
logging.error(f"config should be a dict, but was:")
logging.error(json.dumps(config, indent=2))
return 1
outputs = config.get(OUTPUTS_PARAM)
if not outputs:
logging.error(f"no {OUTPUTS_PARAM} specified in config:")
logging.error(json.dumps(config, indent=2))
return 1
exclude_http_provider = config.get(EXCLUDE_HTTP_PROVIDER_PARAM, False)
if not isinstance(exclude_http_provider, bool):
logging.error(
f'"{EXCLUDE_HTTP_PROVIDER_PARAM}" field must be a boolean, but was `{exclude_http_provider}`'
)
return 1
exclude_github_release_provider = config.get(EXCLUDE_GITHUB_PROVIDER_PARAM, False)
if not isinstance(exclude_github_release_provider, bool):
logging.error(
f'"{EXCLUDE_GITHUB_PROVIDER_PARAM}" field must be a boolean, but was `{exclude_github_release_provider}`'
)
return 1
logging.info("using config:")
logging.info(json.dumps(config, indent=2))
# Collect build metadata from the environment
build_metadata = None
# Convert string argument to boolean
include_metadata_str = str(args.include_build_metadata).lower()
include_metadata = include_metadata_str not in ["false", "0", "no"] and not args.exclude_build_metadata
# Also check for environment variable (for Docker action support)
include_metadata_env = os.getenv("INCLUDE_BUILD_METADATA", "true").lower()
if include_metadata_env in ["false", "0", "no"]:
include_metadata = False
if include_metadata:
build_metadata = collect_build_metadata(args.config)
logging.info("build metadata:")
logging.info(json.dumps(build_metadata, indent=2))
name_to_asset = get_release_assets(tag=tag, github_repository=repo)
logging.info(json.dumps(name_to_asset, indent=2))
for output_filename, output_config in outputs.items():
platform_entries = map_platforms(output_config, name_to_asset)
if not isinstance(platform_entries, dict):
logging.error(f"failed with error type {platform_entries}")
return 1
logging.info(json.dumps(platform_entries, indent=2))
manifest_file_contents = generate_manifest_file(
output_filename,
gh_repo_arg,
tag,
platform_entries,
include_http_provider=not exclude_http_provider,
include_github_release_provider=not exclude_github_release_provider,
build_metadata=build_metadata,
)
logging.info(manifest_file_contents)
output_file = os.path.join(output_folder, output_filename)
with open(output_file, "w") as f:
f.write(manifest_file_contents)
# `chmod +x` if not on Windows.
if not sys.platform.startswith('win'):
fd = f.fileno()
os.fchmod(fd, 0o755)
logging.info(f"wrote manifest to {output_file}")
if args.upload:
# Upload manifest to release, but do not clobber. Note that this may
# fail if this action has been called more than once for the same config.
subprocess.run(
[
"gh",
"release",
"upload",
tag,
output_file,
"--repo",
gh_repo_arg,
],
check=True
)
return 0
def generate_manifest_file(
name: str,
gh_repo_arg: str,
tag: str,
platform_entries,
include_http_provider: bool,
include_github_release_provider: bool,
build_metadata: Optional[Dict[str, Any]] = None,
) -> str:
platforms = {}
with tempfile.TemporaryDirectory() as temp_dir:
for platform_name, platform_entry in platform_entries.items():
asset, platform_config = platform_entry
hash_algo = platform_config.get("hash", "blake3")
size = asset.get("size")
if size is None:
logging.error(f"missing 'size' field in asset: {asset}")
return 1
asset_name = asset.get("name")
if asset_name is None:
logging.error(f"missing 'name' field in asset: {asset}")
return 1
path = platform_config.get("path")
if not path:
logging.error(f"missing `path` field in asset: {asset}")
return 1
if "format" in platform_config:
# If the user is knowingly not using any sort of compression,
# then `"format": null` must be explicitly specified in the JSON.
asset_format = platform_config["format"]
else:
asset_format = guess_artifact_format_from_asset_name(asset_name)
if not asset_format:
logging.error(
f'"format" could not be inferred from asset name: {asset_name} in {asset}, must specify explicitly'
)
return 1
hash_hex = compute_hash(
gh_repo_arg, temp_dir, tag, asset_name, hash_algo, size
)
providers = []
if include_http_provider:
providers.append(
{
"url": asset["url"],
}
)
if include_github_release_provider:
providers.append(
{
"type": "github-release",
"repo": gh_repo_arg,
"tag": tag,
"name": asset_name,
}
)
artifact_entry = {
"size": size,
"hash": hash_algo,
"digest": hash_hex,
"format": asset_format,
"path": path,
"providers": providers,
}
# If `"format": null` was specified, there should not be a "format"
# field in the arifact entry.
if not asset_format:
del artifact_entry["format"]
platforms[platform_name] = artifact_entry
manifest = {
"name": name,
"platforms": platforms,
}
# Add build metadata if available
if build_metadata:
manifest["build_metadata"] = build_metadata
return f"""#!/usr/bin/env dotslash
{json.dumps(manifest, indent=2)}
"""
def map_platforms(
config, name_to_asset: Dict[str, Any]
) -> Union[
Dict[str, Tuple[Any, Any]],
Literal["BothNameAndRegex", "NeitherNameNorRegex", "NoMatchForAsset", "ParseError"],
]:
"""Attempts to take every platform specified in the config and return a map
of platform names to their corresponding asset information. If successful,
each value in the dict will be a tuple of (asset, platform_config).
Note that it is possible that not all assets have been uploaded yet, in
which case "NoMatchForAsset" will be returned.
"""
platforms = config.get("platforms")
if platforms is None:
logging.error("'platforms' field missing from config: {config}")
return "ParseError"
platform_entries = {}
for platform, platform_config in platforms.items():
name = platform_config.get("name")
name_regex = platform_config.get("regex")
if name and name_regex:
logging.error(
f"only one of 'name' and 'regex' should be specified for {platform}"
)
return "BothNameAndRegex"
elif not name and not name_regex:
logging.error(
f"exactly one of 'name' and 'regex' should be specified for {platform}"
)
return "NeitherNameNorRegex"
if name:
# Try to match the name exactly:
for asset_name, asset in name_to_asset.items():
if asset_name == name:
platform_entries[platform] = (asset, platform_config)
break
if platform in platform_entries:
continue
else:
logging.error(f"could not find asset with name '{name}'")
return "NoMatchForAsset"
else:
# Try to match the name using a regular expression.
regex = re.compile(name_regex)
for asset_name, asset in name_to_asset.items():
if regex.match(asset_name):
platform_entries[platform] = (asset, platform_config)
break
if platform in platform_entries:
continue
else:
logging.error(f"could not find asset matching regex '{name_regex}'")
return "NoMatchForAsset"
return platform_entries
@cache
def compute_hash(
gh_repo_arg: str,
temp_dir: str,
tag: str,
name: str,
hash_algo: HashAlgorithm,
size: int,
) -> str:
"""Fetches the release entry corresponding to the specified (tag, name) tuple,
fetches the contents, verifies the size matches, and computes the hash.
Return value is a hex string representing the hash.
"""
output_filename = os.path.join(temp_dir, name)
# Fetch the url using the gh CLI to ensure authentication is handled correctly.
args = [
"gh",
"release",
"download",
tag,
"--repo",
gh_repo_arg,
# --pattern takes a "glob pattern", though we want to match an exact
# filename. Using re.escape() seems to do the right thing, though adding
# ^ and $ appears to break things.
"--pattern",
re.escape(name),
"--output",
output_filename,
]
subprocess.run(args, check=True)
stats = os.stat(output_filename)
if stats.st_size != size:
raise Exception(f"expected size {size} for {name} but got {stats.st_size}")
if hash_algo == "blake3":
import blake3
hasher = blake3.blake3()
with open(output_filename, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hasher.update(chunk)
digest = hasher.digest()
return digest.hex()
elif hash_algo == "sha256":
import hashlib
hasher = hashlib.sha256()
with open(output_filename, "rb") as f:
for chunk in iter(lambda: f.read(4096), b""):
hasher.update(chunk)
return hasher.hexdigest()
def get_config(
*, path_to_config: str, config_ref: str, github_repository: str, api_url: str
) -> Any:
args = [
"gh",
"api",
"-X",
"GET",
f"{api_url}/repos/{github_repository}/contents/{path_to_config}",
"-H",
"Accept: application/vnd.github.raw",
"-f",
f"ref={config_ref}",
]
output = subprocess.check_output(args)
return json.loads(output.decode("utf-8"))
def get_release_assets(*, tag: str, github_repository) -> Dict[str, Any]:
args = [
"gh",
"release",
"view",
tag,
"--repo",
github_repository,
"--json",
"assets",
]
output = subprocess.check_output(args)
release_data = json.loads(output.decode("utf-8"))
assets = release_data.get("assets")
if not assets:
raise Exception(f"no assets found for release '{tag}'")
return {asset["name"]: asset for asset in assets if asset["state"] == "uploaded"}
def guess_artifact_format_from_asset_name(asset_name: str) -> Optional[ArtifactFormat]:
if asset_name.endswith(".tar.gz") or asset_name.endswith(".tgz"):
return "tar.gz"
elif asset_name.endswith(".tar.zst") or asset_name.endswith(".tzst"):
return "tar.zst"
elif asset_name.endswith(".tar.xz"):
return "tar.xz"
elif asset_name.endswith(".tar"):
return "tar"
elif asset_name.endswith(".gz"):
return "gz"
elif asset_name.endswith(".zst"):
return "zst"
elif asset_name.endswith(".xz"):
return "xz"
elif asset_name.endswith(".zip"):
return "zip"
else:
return None
def parse_args():
parser = argparse.ArgumentParser(
description="Generate DotSlash files for a GitHub release"
)
parser.add_argument("--tag", required=True, help="tag identifying the release")
parser.add_argument("--config", required=True, help="path to JSON config file")
parser.add_argument(
"--local-config",
action="store_true",
help="if specified, --config is treated as a local path and --config-ref is ignored",
)
parser.add_argument(
"--repo",
help="github repo specified in `ORG/REPO` format",
default=os.getenv("GITHUB_REPOSITORY"),
)
parser.add_argument(
"--upload",
action="store_true",
help="if specified, upload the generated DotSlash files to the release",
)
# It would make things slightly easier for the user to default to the
# default branch of the repo, which might not be main.
default_config_ref = "main"
parser.add_argument(
"--config-ref",
help=f"SHA of Git commit to look up the config, defaults to {default_config_ref}",
default=os.getenv("GITHUB_SHA", default_config_ref),
)
default_server = "https://github.com"
parser.add_argument(
"--server",
help=f"URL for the GitHub server, defaults to {default_server}",
default=os.getenv("GITHUB_SERVER_URL", default_server),
)
default_api_server = "https://api.github.com"
parser.add_argument(
"--api-server",
help=f"URL for the GitHub API server, defaults to {default_api_server}",
default=os.getenv("GITHUB_API_URL", default_api_server),
)
parser.add_argument(
"--output",
help=f"folder where DotSlash files should be written, defaults to $GITHUB_WORKSPACE",
default=os.getenv("GITHUB_WORKSPACE"),
)
parser.add_argument(
"--include-build-metadata",
type=str,
default="true",
help="include build metadata in the generated DotSlash files (default: true)",
)
parser.add_argument(
"--exclude-build-metadata",
action="store_true",
help="exclude build metadata from the generated DotSlash files",
)
return parser.parse_args()
if __name__ == "__main__":
main()