R/github.R

Defines functions post_release news_tpl gh_run gh git_pull_current git_test_branch git_co git github_token github_api github_releases2 reg_match github_releases

Documented in github_api github_releases post_release

#' Get the tags of GitHub releases of a repository
#'
#' Use the GitHub API ([github_api()]) to obtain the tags of the
#' releases.
#' @param repo The repository name of the form `user/repo`, e.g.,
#'   `"yihui/xfun"`.
#' @param tag A tag as a character string. If provided, it will be returned if
#'   the tag exists. If `tag = "latest"`, the tag of the latest release is
#'   returned.
#' @param pattern A regular expression to match the tags.
#' @param use_jsonlite Whether to use \pkg{jsonlite} to parse the releases info.
#' @export
#' @return A character vector of (GIT) tags.
#' @examplesIf interactive()
#' xfun::github_releases('yihui/litedown')
#' xfun::github_releases('gohugoio/hugo', 'latest')
github_releases = function(
  repo, tag = '', pattern = 'v[0-9.]+', use_jsonlite = loadable('jsonlite')
) {
  if (tag != '') return(github_releases2(repo, tag, pattern))

  i = 1; v = character()
  repeat {
    res = github_api(
      sprintf('/repos/%s/tags', repo), NULL, list(per_page = 100, page = i),
      raw = !use_jsonlite
    )
    v2 = unlist(if (use_jsonlite) {
      lapply(res, `[[`, 'name')
    } else {
      reg_match('\\{"name":"([^"]+)",', res)
    })
    if (length(v2) == 0) break
    v = c(v, v2)
    if (length(v2) < 100) break  # not enough items for the next page
    i = i + 1
  }
  grep(sprintf('^%s$', pattern), unique(v), value = TRUE)
}

# extract the matched elements in the n-th pair of () in the regex
reg_match = function(p, x, n = 1, ...) {
  # TODO: gregexec was added in R 4.1.0; remove this workaround when we don't
  # need to support R < 4.1.0
  v = 'gregexec' %in% ls(baseenv())
  m = (if (v) base::gregexec else base::gregexpr)(p, x, ...)
  lapply(regmatches(x, m), function(x) {
    if (v) x[n + 1, ] else gsub(p, paste0('\\', n), x)
  })
}

# the fallback method to retrieve release tags (read HTML source)
github_releases2 = function(repo, tag = '', pattern = '[^"&]+') {
  read = function() suppressWarnings(
    read_utf8(sprintf('https://github.com/%s/releases/%s', repo, tag))
  )
  h = if (tag == '') read() else tryCatch(read(), error = function(e) '')
  r = sprintf('^.*?%s/releases/tag/(%s)".*', repo, pattern)
  v = unique(grep_sub(r, '\\1', h))
  # if a tag is specified, use the 1st value because other tags may be mentioned
  # in the current tag page
  if (tag == '') v else head(v, 1)
}

#' @details `github_api()` is a wrapper function based on
#'   `rest_api_raw()` to obtain data from the GitHub API:
#'   <https://docs.github.com/en/rest>. You can provide a personal access
#'   token (PAT) via the `token` argument, or via one of the environment
#'   variables \var{GITHUB_PAT}, \var{GITHUB_TOKEN}, \var{GH_TOKEN}. A PAT
#'   allows for a much higher rate limit in API calls. Without a token, you can
#'   only make 60 calls in an hour.
#' @param raw Whether to return the raw response or parse the response with
#'   \pkg{jsonlite}.
#' @rdname rest_api
#' @export
github_api = function(
  endpoint, token = '', params = list(), headers = NULL, raw = !loadable('jsonlite')
) {
  token = c(token, github_token())
  token = if (length(token <- token[token != ''])) token[1] else ''
  names(token) = 'token'
  error = TRUE
  on.exit(if (error) github_token(NA, token))
  res = rest_api_raw('https://api.github.com', endpoint, token, params, headers)
  error = FALSE
  if (raw) res else jsonlite::fromJSON(res, FALSE)
}

# the environment variables to check for a GitHub personal access token
github_envs = c('GITHUB_PAT', 'GITHUB_TOKEN', 'GH_TOKEN')

github_token = function(error = FALSE, token = unname(Sys.getenv(github_envs))) {
  if (length(token <- token[token != ''])) return(token[1])
  msg = c(
    'You may need to save a GitHub personal access token in one of the ',
    'environment variables: ', paste(github_envs, collapse = ', ')
  )
  if (is.na(error)) message(msg) else if (error) stop(msg)
  ''
}

git = function(...) {
  if (Sys.which('git') == '') stop('git is not available')
  # R's HOME var is different from the system's HOME on Windows:
  # https://github.com/yihui/crandalf/issues/24
  if (is_windows()) {
    env = set_envvar(c(HOME = Sys.getenv('USERPROFILE')))
    on.exit(set_envvar(env), add = TRUE)
  }
  system3('git', ...)
}

git_co = function(args = NULL, ...) {
  git(c('checkout', args), ...)
}

git_test_branch = function() {
  if (length(d <- git(c('diff', '--name-only'), stdout = TRUE))) stop(
    'The current branch has changes not stated for commit:\n',
    paste(d, collapse = '\n')
  )
}

# pull from the current branch if it is a git repository and has a current branch
git_pull_current <- function() {
  is_repo = git(c('rev-parse', '--is-inside-work-tree'), stdout = FALSE, stderr = FALSE)
  if (is_repo == 0) {
    branch = git(c('branch', '--show-current'), stdout = TRUE)
    if (length(branch) == 1 && branch != '') {
      if (length(git('diff', stdout = TRUE))) {
        git('stash'); on.exit(git(c('stash', 'pop')), add = TRUE)
      }
      git(c('pull', 'origin', branch))
    }
  }
}

gh = function(...) {
  if (Sys.which('gh') == '') stop('GitHub CLI not found: https://cli.github.com')
  system3('gh', ...)
}

gh_run = function(..., repo = NA) {
  gh(c(if (!is.na(repo)) c('-R', repo), 'run', ...), stdout = TRUE)
}

# Detect the sprintf template for NEWS.md headers from existing content.
# Falls back to '# CHANGES IN %s VERSION %s' when format cannot be inferred.
news_tpl = function(news, pkg, ver) {
  h1 = grep('^# ', news, value = TRUE)[1]
  tpl = sub(sprintf('\\b%s\\b', ver), '%s', sub(sprintf('\\b%s\\b', pkg), '%s', h1))
  if (grepl('%s.*%s', tpl)) tpl else '# CHANGES IN %s VERSION %s'
}

#' Perform post-CRAN-release routine tasks
#'
#' After a CRAN release is accepted, commit and tag the release, bump the
#' package version to the next patch dev version, prepend a new header to
#' \file{NEWS.md}, push to the remote repository, and create a GitHub release
#' with the news items of this version.
#'
#' The version number `X.Y` or `X.Y.0` is extracted from the `Version` field in
#' \file{DESCRIPTION}. For example, if the current version is `0.58` (or
#' `0.58.0`), the repo will be tagged as `v0.58` (or `v0.58.0`). The patch
#' version is then bumped to `0.58.1`, and a new header for the next minor
#' version `0.59` (or `0.59.0`) is prepended to \file{NEWS.md}.
#' @export
post_release = function() {
  desc = read.dcf('DESCRIPTION')
  pkg = desc[, 'Package']
  ver = desc[, 'Version']

  vers = release_versions(ver)
  ver_next = vers$next_ver
  ver_patch = vers$patch

  # Step 1: commit all current changes (only if Version was modified)
  ver_in_head = system3(
    'git', c('show', 'HEAD:DESCRIPTION'), stdout = TRUE, stderr = FALSE
  )
  ver_in_head = trimws(grep_sub('^Version:\\s+(.*)', '\\1', ver_in_head))
  if (!identical(ver_in_head, ver)) {
    message('Committing CRAN release v', ver, ' ...')
    git(c('commit', '-a', '-m', sprintf('CRAN release v%s', ver)))
  }

  # Step 2: tag
  message('Tagging v', ver, ' ...')
  git(c('tag', sprintf('v%s', ver)))

  # Step 3: bump patch version and prepend NEWS.md
  process_file('DESCRIPTION', function(x) {
    ver_line = grep('^Version:\\s+', x)
    x[ver_line] = sprintf('Version: %s', ver_patch)
    x
  })

  news = trimws(read_utf8('NEWS.md'))
  tpl = news_tpl(news, pkg, ver)
  news_hdr = function(v) sprintf(tpl, pkg, v)

  process_file('NEWS.md', function(x) c(paste0(news_hdr(ver_next), '\n\n'), x))

  hdr = news_hdr(ver)
  i = which(hdr == news)
  if (length(i) == 0) stop('Cannot find the header "', hdr, '" in NEWS.md')

  message('Committing the start of the next version ...')
  git(c('commit', '-a', '-m', 'start the next version'))

  # Step 4: push with tags
  message('Pushing to remote ...')
  git(c('push', '--tags', 'origin', 'HEAD'))

  # Step 5: create GitHub release
  github_token(TRUE)
  j = grep('^# ', news)
  j = j[j > i]
  end = if (length(j) > 0) j[1] - 1 else length(news)
  items = strip_blank(news[(i + 1):end])

  f = tempfile()
  writeLines(items, f)
  on.exit(unlink(f), add = TRUE)

  gh(c(
    'release', 'create', sprintf('v%s', ver),
    '--title', sprintf('%s %s', pkg, ver), '--notes-file', f)
  )

  invisible()
}

Try the xfun package in your browser

Any scripts or data that you put into this service are public.

xfun documentation built on July 9, 2026, 5:08 p.m.